From f34cdc0038db3d3e62a82ed05d78a14283e6cb02 Mon Sep 17 00:00:00 2001 From: Anand Krishnamoorthi Date: Tue, 5 Aug 2025 14:20:39 -0500 Subject: [PATCH 1/2] feat: Add Schema Registry and Validation Framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- src/schema/registry.rs | 224 ++++ src/schema/tests.rs | 3 + src/schema/tests/effect.rs | 970 ++++++++++++++++++ src/schema/tests/registry.rs | 503 +++++++++ src/schema/tests/resource.rs | 1878 ++++++++++++++++++++++++++++++++++ 5 files changed, 3578 insertions(+) create mode 100644 src/schema/registry.rs create mode 100644 src/schema/tests/effect.rs create mode 100644 src/schema/tests/registry.rs create mode 100644 src/schema/tests/resource.rs diff --git a/src/schema/registry.rs b/src/schema/registry.rs new file mode 100644 index 00000000..c1cc23e4 --- /dev/null +++ b/src/schema/registry.rs @@ -0,0 +1,224 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#![allow(dead_code)] +use crate::{schema::Schema, *}; +use dashmap::DashMap; + +type String = Rc; + +/// Errors that can occur when interacting with the SchemaRegistry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SchemaRegistryError { + AlreadyExists(String), + InvalidName(String), +} + +impl fmt::Display for SchemaRegistryError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SchemaRegistryError::AlreadyExists(name) => { + write!(f, "Schema registration failed: A schema with the name '{name}' is already registered.") + } + SchemaRegistryError::InvalidName(name) => { + write!(f, "Schema registration failed: The name '{name}' is invalid (empty or whitespace-only names are not allowed).") + } + } + } +} + +impl core::error::Error for SchemaRegistryError {} + +/// Validates that a schema name is not empty or whitespace-only. +fn validate_name(name: &str) -> Result<(), SchemaRegistryError> { + if name.is_empty() || name.trim().is_empty() { + Err(SchemaRegistryError::InvalidName(String::from(name))) + } else { + Ok(()) + } +} + +/// Thread-safe registry for Schemas using DashMap. +#[derive(Clone, Default)] +pub struct SchemaRegistry { + inner: DashMap>, +} + +#[cfg(feature = "arc")] +lazy_static::lazy_static! { + /// Global singleton instance of resource schemas registry. + /// Only available when using Arc (thread-safe) reference counting. + pub static ref RESOURCE_SCHEMA_REGISTRY: SchemaRegistry = SchemaRegistry::new(); +} + +#[cfg(feature = "arc")] +lazy_static::lazy_static! { + /// Global singleton instance of effect schemas registry. + /// Only available when using Arc (thread-safe) reference counting. + pub static ref EFFECT_SCHEMA_REGISTRY: SchemaRegistry = SchemaRegistry::new(); +} + +impl SchemaRegistry { + /// Create a new, empty registry. + pub fn new() -> Self { + Self { + inner: DashMap::new(), + } + } + + /// Register a schema with a given name. Returns Err if name already exists. + pub fn register( + &self, + name: impl Into, + schema: Rc, + ) -> Result<(), SchemaRegistryError> { + let name = name.into(); + + // Validate the name first + validate_name(&name)?; + + use dashmap::mapref::entry::Entry; + match self.inner.entry(name) { + Entry::Occupied(e) => Err(SchemaRegistryError::AlreadyExists(e.key().clone())), + Entry::Vacant(e) => { + e.insert(schema); + Ok(()) + } + } + } + + /// Retrieve a schema by name, if it exists. + pub fn get(&self, name: &str) -> Option> { + self.inner.get(name).map(|entry| Rc::clone(entry.value())) + } + + /// Remove a schema by name. Returns the removed schema if it existed. + pub fn remove(&self, name: &str) -> Option> { + self.inner.remove(name).map(|(_, v)| v) + } + + /// List all registered schema names. + pub fn list_names(&self) -> Vec { + self.inner.iter().map(|entry| entry.key().clone()).collect() + } + + /// Check if a schema with the given name exists. + pub fn contains(&self, name: &str) -> bool { + self.inner.contains_key(name) + } + + /// Get the number of registered schemas. + 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 schemas from the registry. + pub fn clear(&self) { + self.inner.clear(); + } +} + +/// Helper functions for resource schema registry operations. +/// Only available when using Arc (thread-safe) reference counting. +#[cfg(feature = "arc")] +pub mod resource { + use super::*; + + /// Register a resource schema with a given name. + pub fn register( + name: impl Into, + schema: Rc, + ) -> Result<(), SchemaRegistryError> { + RESOURCE_SCHEMA_REGISTRY.register(name, schema) + } + + /// Retrieve a resource schema by name. + pub fn get(name: &str) -> Option> { + RESOURCE_SCHEMA_REGISTRY.get(name) + } + + /// Remove a resource schema by name. + pub fn remove(name: &str) -> Option> { + RESOURCE_SCHEMA_REGISTRY.remove(name) + } + + /// List all registered resource schema names. + pub fn list_names() -> Vec { + RESOURCE_SCHEMA_REGISTRY.list_names() + } + + /// Check if a resource schema with the given name exists. + pub fn contains(name: &str) -> bool { + RESOURCE_SCHEMA_REGISTRY.contains(name) + } + + /// Get the number of registered resource schemas. + pub fn len() -> usize { + RESOURCE_SCHEMA_REGISTRY.len() + } + + /// Check if the resource schema registry is empty. + pub fn is_empty() -> bool { + RESOURCE_SCHEMA_REGISTRY.is_empty() + } + + /// Clear all resource schemas from the registry. + pub fn clear() { + RESOURCE_SCHEMA_REGISTRY.clear(); + } +} + +/// Helper functions for effect schema registry operations. +/// Only available when using Arc (thread-safe) reference counting. +#[cfg(feature = "arc")] +pub mod effect { + use super::*; + + /// Register an effect schema with a given name. + pub fn register( + name: impl Into, + schema: Rc, + ) -> Result<(), SchemaRegistryError> { + EFFECT_SCHEMA_REGISTRY.register(name, schema) + } + + /// Retrieve an effect schema by name. + pub fn get(name: &str) -> Option> { + EFFECT_SCHEMA_REGISTRY.get(name) + } + + /// Remove an effect schema by name. + pub fn remove(name: &str) -> Option> { + EFFECT_SCHEMA_REGISTRY.remove(name) + } + + /// List all registered effect schema names. + pub fn list_names() -> Vec { + EFFECT_SCHEMA_REGISTRY.list_names() + } + + /// Check if an effect schema with the given name exists. + pub fn contains(name: &str) -> bool { + EFFECT_SCHEMA_REGISTRY.contains(name) + } + + /// Get the number of registered effect schemas. + pub fn len() -> usize { + EFFECT_SCHEMA_REGISTRY.len() + } + + /// Check if the effect schema registry is empty. + pub fn is_empty() -> bool { + EFFECT_SCHEMA_REGISTRY.is_empty() + } + + /// Clear all effect schemas from the registry. + pub fn clear() { + EFFECT_SCHEMA_REGISTRY.clear(); + } +} diff --git a/src/schema/tests.rs b/src/schema/tests.rs index 2093adb6..712a420f 100644 --- a/src/schema/tests.rs +++ b/src/schema/tests.rs @@ -2,5 +2,8 @@ // Licensed under the MIT License. mod azure; +mod effect; +mod registry; +mod resource; mod suite; mod validate; diff --git a/src/schema/tests/effect.rs b/src/schema/tests/effect.rs new file mode 100644 index 00000000..97640d45 --- /dev/null +++ b/src/schema/tests/effect.rs @@ -0,0 +1,970 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::super::registry::*; +use crate::{ + schema::{validate::SchemaValidator, validate::ValidationError, Schema, Type}, + *, +}; +use serde_json::json; + +type String = Rc; + +use std::sync::Mutex; + +lazy_static::lazy_static! { + static ref EFFECT_TEST_LOCK: Mutex<()> = Mutex::new(()); +} + +// Helper function to create a schema for Azure Policy effects +fn create_effect_schema() -> Rc { + 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 { + 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 { + 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 { + 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() { + #[cfg(feature = "std")] + let _lock = EFFECT_TEST_LOCK.lock().unwrap(); + + let registry = SchemaRegistry::new(); + let effect_schema = create_effect_schema(); + + // Test registration of basic effect enum schema + let result = registry.register("azure.policy.effect", effect_schema.clone()); + assert!(result.is_ok()); + assert!(registry.contains("azure.policy.effect")); + assert_eq!(registry.len(), 1); + + // Verify schema can be retrieved + let retrieved = registry.get("azure.policy.effect"); + assert!(retrieved.is_some()); + assert!(Rc::ptr_eq(&effect_schema, &retrieved.unwrap())); +} + +#[test] +fn test_deny_effect_schema() { + #[cfg(feature = "std")] + let _lock = EFFECT_TEST_LOCK.lock().unwrap(); + + let registry = SchemaRegistry::new(); + let deny_schema = create_deny_effect_schema(); + + // Test registration of deny effect schema + let result = registry.register("azure.policy.deny", deny_schema.clone()); + assert!(result.is_ok()); + assert!(registry.contains("azure.policy.deny")); + + // Verify schema structure + match deny_schema.as_type() { + Type::Object { + properties, + required, + .. + } => { + assert!(properties.contains_key("effect")); + assert!(properties.contains_key("description")); + assert!(required.clone().unwrap().contains(&"effect".into())); + } + _ => panic!("Expected deny schema to be an object type"), + } +} + +#[test] +fn test_audit_effect_schema() { + #[cfg(feature = "std")] + let _lock = EFFECT_TEST_LOCK.lock().unwrap(); + + let registry = SchemaRegistry::new(); + let audit_schema = create_audit_effect_schema(); + + // Test registration of audit effect schema + let result = registry.register("azure.policy.audit", audit_schema.clone()); + assert!(result.is_ok()); + assert!(registry.contains("azure.policy.audit")); + + // Verify schema structure + match audit_schema.as_type() { + Type::Object { + properties, + required, + .. + } => { + assert!(properties.contains_key("effect")); + assert!(properties.contains_key("description")); + assert!(properties.contains_key("auditDetails")); + if let Some(req) = required { + assert!(req.contains(&"effect".into())); + } else { + panic!("Expected required field to be present"); + } + + // Check auditDetails structure + let audit_details = properties.get("auditDetails").unwrap(); + match audit_details.as_type() { + Type::Object { + properties: audit_props, + .. + } => { + assert!(audit_props.contains_key("category")); + assert!(audit_props.contains_key("severity")); + } + _ => panic!("Expected auditDetails to be an object"), + } + } + _ => panic!("Expected audit schema to be an object type"), + } +} + +#[test] +fn test_modify_effect_schema() { + #[cfg(feature = "std")] + let _lock = EFFECT_TEST_LOCK.lock().unwrap(); + + let registry = SchemaRegistry::new(); + let modify_schema = create_modify_effect_schema(); + + // Test registration of modify effect schema + let result = registry.register("azure.policy.modify", modify_schema.clone()); + assert!(result.is_ok()); + assert!(registry.contains("azure.policy.modify")); + + // Verify schema structure + match modify_schema.as_type() { + Type::Object { + properties, + required, + .. + } => { + assert!(properties.contains_key("effect")); + assert!(properties.contains_key("description")); + assert!(properties.contains_key("modifyDetails")); + assert!(required.as_ref().unwrap().contains(&"effect".into())); + assert!(required.as_ref().unwrap().contains(&"modifyDetails".into())); + + // Check modifyDetails structure + let modify_details = properties.get("modifyDetails").unwrap(); + match modify_details.as_type() { + Type::Object { + properties: modify_props, + required: modify_required, + .. + } => { + assert!(modify_props.contains_key("roleDefinitionIds")); + assert!(modify_props.contains_key("operations")); + assert!(modify_required + .as_ref() + .unwrap() + .contains(&"roleDefinitionIds".into())); + assert!(modify_required + .as_ref() + .unwrap() + .contains(&"operations".into())); + } + _ => panic!("Expected modifyDetails to be an object"), + } + } + _ => panic!("Expected modify schema to be an object type"), + } +} + +#[test] +fn test_multiple_effect_schemas() { + #[cfg(feature = "std")] + let _lock = EFFECT_TEST_LOCK.lock().unwrap(); + + let registry = SchemaRegistry::new(); + + // Register all effect schemas + let deny_schema = create_deny_effect_schema(); + let audit_schema = create_audit_effect_schema(); + let modify_schema = create_modify_effect_schema(); + + assert!(registry.register("azure.policy.deny", deny_schema).is_ok()); + assert!(registry + .register("azure.policy.audit", audit_schema) + .is_ok()); + assert!(registry + .register("azure.policy.modify", modify_schema) + .is_ok()); + + // Verify all are registered + assert_eq!(registry.len(), 3); + assert!(registry.contains("azure.policy.deny")); + assert!(registry.contains("azure.policy.audit")); + assert!(registry.contains("azure.policy.modify")); + + // Verify they can all be retrieved + assert!(registry.get("azure.policy.deny").is_some()); + assert!(registry.get("azure.policy.audit").is_some()); + assert!(registry.get("azure.policy.modify").is_some()); + + // List all names + let names = registry.list_names(); + assert_eq!(names.len(), 3); + assert!(names.contains(&"azure.policy.deny".into())); + assert!(names.contains(&"azure.policy.audit".into())); + assert!(names.contains(&"azure.policy.modify".into())); +} + +#[test] +fn test_global_effect_registry() { + let _lock = EFFECT_TEST_LOCK.lock().unwrap(); + + // Clear registry + effect::clear(); + + // Register Azure Policy effects + let deny_schema = create_deny_effect_schema(); + let audit_schema = create_audit_effect_schema(); + let modify_schema = create_modify_effect_schema(); + + assert!(effect::register("azure.policy.deny", deny_schema).is_ok()); + std::dbg!(effect::list_names()); + assert!(effect::register("azure.policy.audit", audit_schema).is_ok()); + std::dbg!(effect::list_names()); + assert!(effect::register("azure.policy.modify", modify_schema).is_ok()); + std::dbg!(effect::list_names()); + // Verify all are registered in global registry + + assert_eq!(effect::len(), 3); + assert!(effect::contains("azure.policy.deny")); + assert!(effect::contains("azure.policy.audit")); + assert!(effect::contains("azure.policy.modify")); + + // Test retrieval from global registry + let retrieved_deny = effect::get("azure.policy.deny"); + let retrieved_audit = effect::get("azure.policy.audit"); + let retrieved_modify = effect::get("azure.policy.modify"); + + assert!(retrieved_deny.is_some()); + assert!(retrieved_audit.is_some()); + assert!(retrieved_modify.is_some()); + + // Clean up + effect::clear(); +} + +#[test] +fn test_effect_schema_validation_patterns() { + #[cfg(feature = "std")] + let _lock = EFFECT_TEST_LOCK.lock().unwrap(); + + let registry = SchemaRegistry::new(); + + // 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 = registry.register("azure.policy.complex", schema_rc); + assert!(result.is_ok()); + assert!(registry.contains("azure.policy.complex")); +} + +#[test] +fn test_effect_schema_with_invalid_names() { + #[cfg(feature = "std")] + let _lock = EFFECT_TEST_LOCK.lock().unwrap(); + + let registry = SchemaRegistry::new(); + let effect_schema = create_effect_schema(); + + // Test invalid names + assert!(registry.register("", effect_schema.clone()).is_err()); + assert!(registry.register(" ", effect_schema.clone()).is_err()); + assert!(registry.register("\t", effect_schema.clone()).is_err()); + assert!(registry.register("\n", effect_schema).is_err()); + + // Verify registry is empty + assert!(registry.is_empty()); +} + +#[test] +fn test_effect_schema_duplicate_registration() { + #[cfg(feature = "std")] + let _lock = EFFECT_TEST_LOCK.lock().unwrap(); + + let registry = SchemaRegistry::new(); + let deny_schema = create_deny_effect_schema(); + + // First registration should succeed + assert!(registry + .register("azure.policy.deny", deny_schema.clone()) + .is_ok()); + assert_eq!(registry.len(), 1); + + // Duplicate registration should fail + let duplicate_result = registry.register("azure.policy.deny", 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"); + } + _ => panic!("Expected AlreadyExists error"), + } + + // Registry should still have only one entry + assert_eq!(registry.len(), 1); +} + +#[test] +fn test_azure_policy_effect_removal() { + #[cfg(feature = "std")] + let _lock = EFFECT_TEST_LOCK.lock().unwrap(); + + let registry = SchemaRegistry::new(); + + // Register multiple Azure Policy effects + let effects = vec![ + ("azure.policy.deny", create_deny_effect_schema()), + ("azure.policy.audit", create_audit_effect_schema()), + ("azure.policy.modify", create_modify_effect_schema()), + ]; + + for (name, schema) in &effects { + assert!(registry.register(*name, schema.clone()).is_ok()); + } + + assert_eq!(registry.len(), 3); + + // Remove one effect + let removed = registry.remove("azure.policy.audit"); + assert!(removed.is_some()); + assert_eq!(registry.len(), 2); + assert!(!registry.contains("azure.policy.audit")); + + // 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!(registry.contains("azure.policy.deny")); + assert!(registry.contains("azure.policy.modify")); +} + +#[test] +#[cfg(feature = "std")] +fn test_concurrent_effect_schema_access() { + let _lock = EFFECT_TEST_LOCK.lock().unwrap(); + + use std::sync::Barrier; + use std::thread; + + // Create isolated registry for this test + let test_registry = Rc::new(SchemaRegistry::new()); + let barrier = Rc::new(Barrier::new(3)); + let mut handles = vec![]; + + // Test concurrent registration of different Azure Policy effects + let effects = [ + "azure.policy.deny", + "azure.policy.audit", + "azure.policy.modify", + ]; + + for (i, effect_name) in effects.iter().enumerate() { + let barrier = Rc::clone(&barrier); + let registry = Rc::clone(&test_registry); + let name: String = (*effect_name).into(); + + let handle: thread::JoinHandle> = + 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(); + 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 3 effect schemas registered + assert_eq!(test_registry.len(), 3); + assert!(test_registry.contains("azure.policy.deny")); + assert!(test_registry.contains("azure.policy.audit")); + assert!(test_registry.contains("azure.policy.modify")); +} + +#[test] +fn test_azure_policy_effect_clear() { + #[cfg(feature = "std")] + let _lock = EFFECT_TEST_LOCK.lock().unwrap(); + + let registry = SchemaRegistry::new(); + + // Register multiple Azure Policy effects + assert!(registry + .register("azure.policy.deny", create_deny_effect_schema()) + .is_ok()); + assert!(registry + .register("azure.policy.audit", create_audit_effect_schema()) + .is_ok()); + assert!(registry + .register("azure.policy.modify", create_modify_effect_schema()) + .is_ok()); + + assert_eq!(registry.len(), 3); + assert!(!registry.is_empty()); + + // Clear all effects + registry.clear(); + + assert_eq!(registry.len(), 0); + assert!(registry.is_empty()); + assert!(registry.list_names().is_empty()); + + // Verify specific effects are no longer present + assert!(!registry.contains("azure.policy.deny")); + assert!(!registry.contains("azure.policy.audit")); + assert!(!registry.contains("azure.policy.modify")); +} + +// 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), + } +} diff --git a/src/schema/tests/registry.rs b/src/schema/tests/registry.rs new file mode 100644 index 00000000..e68aac3e --- /dev/null +++ b/src/schema/tests/registry.rs @@ -0,0 +1,503 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::super::registry::*; +use crate::{schema::Schema, *}; +use serde_json::json; + +type String = Rc; + +#[test] +fn test_schema_registry_new() { + let registry = SchemaRegistry::new(); + assert!(registry.is_empty()); + assert_eq!(registry.len(), 0); +} + +#[test] +fn test_schema_registry_register_success() { + let registry = SchemaRegistry::new(); + 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(); + 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()); + assert!(name.contains("test_schema")); + } else { + panic!("Expected AlreadyExists error"); + } +} + +#[test] +fn test_schema_registry_get() { + let registry = SchemaRegistry::new(); + 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(); + 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(); + + // 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(); + 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("test_schema".into()); + let error_message = format!("{error}"); + assert_eq!( + error_message, + "Schema registration failed: A schema with the name 'test_schema' is already registered." + ); + + let invalid_error = SchemaRegistryError::InvalidName(" ".into()); + let invalid_error_message = format!("{invalid_error}"); + assert_eq!(invalid_error_message, "Schema 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()); + + 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()); + + 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 { + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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()); +} diff --git a/src/schema/tests/resource.rs b/src/schema/tests/resource.rs new file mode 100644 index 00000000..ecd15260 --- /dev/null +++ b/src/schema/tests/resource.rs @@ -0,0 +1,1878 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::super::registry::*; +use crate::{ + schema::{validate::SchemaValidator, validate::ValidationError, Schema, Type}, + *, +}; +use serde_json::json; + +type String = Rc; + +use std::sync::Mutex; + +lazy_static::lazy_static! { + static ref RESOURCE_TEST_LOCK: Mutex<()> = Mutex::new(()); +} + +// Helper function to create a schema for Azure Resource types +fn create_resource_schema() -> Rc { + 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 { + 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 { + 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 { + 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 _lock = RESOURCE_TEST_LOCK.lock().unwrap(); + + let registry = SchemaRegistry::new(); + let resource_schema = create_resource_schema(); + + // Test registration of basic resource enum schema + let result = registry.register("azure.resource.types", resource_schema.clone()); + assert!(result.is_ok()); + assert!(registry.contains("azure.resource.types")); + assert_eq!(registry.len(), 1); + + // Verify schema can be retrieved + let retrieved = registry.get("azure.resource.types"); + assert!(retrieved.is_some()); + assert!(Rc::ptr_eq(&resource_schema, &retrieved.unwrap())); +} + +#[test] +fn test_vm_resource_schema() { + let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); + + let registry = SchemaRegistry::new(); + let vm_schema = create_vm_resource_schema(); + + // Test registration of VM resource schema + let result = registry.register("azure.resource.vm", vm_schema.clone()); + assert!(result.is_ok()); + assert!(registry.contains("azure.resource.vm")); + + // Verify schema structure + match vm_schema.as_type() { + Type::Object { + properties, + required, + .. + } => { + assert!(properties.contains_key("type")); + assert!(properties.contains_key("apiVersion")); + assert!(properties.contains_key("name")); + assert!(properties.contains_key("location")); + assert!(properties.contains_key("properties")); + + if let Some(req) = required { + assert!(req.contains(&"type".into())); + assert!(req.contains(&"apiVersion".into())); + assert!(req.contains(&"name".into())); + assert!(req.contains(&"location".into())); + assert!(req.contains(&"properties".into())); + } else { + panic!("Expected required fields to be present"); + } + + // Check properties structure + let vm_properties = properties.get("properties").unwrap(); + match vm_properties.as_type() { + Type::Object { + properties: vm_props, + .. + } => { + assert!(vm_props.contains_key("hardwareProfile")); + assert!(vm_props.contains_key("osProfile")); + } + _ => panic!("Expected properties to be an object"), + } + } + _ => panic!("Expected VM schema to be an object type"), + } +} + +#[test] +fn test_storage_resource_schema() { + let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); + + let registry = SchemaRegistry::new(); + let storage_schema = create_storage_resource_schema(); + + // Test registration of storage resource schema + let result = registry.register("azure.resource.storage", storage_schema.clone()); + assert!(result.is_ok()); + assert!(registry.contains("azure.resource.storage")); + + // Verify schema structure + match storage_schema.as_type() { + Type::Object { + properties, + required, + .. + } => { + assert!(properties.contains_key("type")); + assert!(properties.contains_key("apiVersion")); + assert!(properties.contains_key("name")); + assert!(properties.contains_key("location")); + assert!(properties.contains_key("sku")); + assert!(properties.contains_key("kind")); + + if let Some(req) = required { + assert!(req.contains(&"type".into())); + assert!(req.contains(&"apiVersion".into())); + assert!(req.contains(&"name".into())); + assert!(req.contains(&"location".into())); + assert!(req.contains(&"sku".into())); + assert!(req.contains(&"kind".into())); + } else { + panic!("Expected required fields to be present"); + } + + // Check sku structure + let sku = properties.get("sku").unwrap(); + match sku.as_type() { + Type::Object { + properties: sku_props, + .. + } => { + assert!(sku_props.contains_key("name")); + } + _ => panic!("Expected sku to be an object"), + } + } + _ => panic!("Expected storage schema to be an object type"), + } +} + +#[test] +fn test_network_resource_schema() { + let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); + + let registry = SchemaRegistry::new(); + let network_schema = create_network_resource_schema(); + + // Test registration of network resource schema + let result = registry.register("azure.resource.network", network_schema.clone()); + assert!(result.is_ok()); + assert!(registry.contains("azure.resource.network")); + + // Verify schema structure + match network_schema.as_type() { + Type::Object { + properties, + required, + .. + } => { + assert!(properties.contains_key("type")); + assert!(properties.contains_key("apiVersion")); + assert!(properties.contains_key("name")); + assert!(properties.contains_key("location")); + assert!(properties.contains_key("properties")); + + if let Some(req) = required { + assert!(req.contains(&"type".into())); + assert!(req.contains(&"apiVersion".into())); + assert!(req.contains(&"name".into())); + assert!(req.contains(&"location".into())); + assert!(req.contains(&"properties".into())); + } else { + panic!("Expected required fields to be present"); + } + + // Check properties structure + let net_properties = properties.get("properties").unwrap(); + match net_properties.as_type() { + Type::Object { + properties: net_props, + .. + } => { + assert!(net_props.contains_key("addressSpace")); + } + _ => panic!("Expected properties to be an object"), + } + } + _ => panic!("Expected network schema to be an object type"), + } +} + +#[test] +fn test_multiple_resource_schemas() { + let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); + + let registry = SchemaRegistry::new(); + + // Register all resource schemas + let vm_schema = create_vm_resource_schema(); + let storage_schema = create_storage_resource_schema(); + let network_schema = create_network_resource_schema(); + + assert!(registry.register("azure.resource.vm", vm_schema).is_ok()); + assert!(registry + .register("azure.resource.storage", storage_schema) + .is_ok()); + assert!(registry + .register("azure.resource.network", network_schema) + .is_ok()); + + // Verify all are registered + assert_eq!(registry.len(), 3); + assert!(registry.contains("azure.resource.vm")); + assert!(registry.contains("azure.resource.storage")); + assert!(registry.contains("azure.resource.network")); + + // Verify they can all be retrieved + assert!(registry.get("azure.resource.vm").is_some()); + assert!(registry.get("azure.resource.storage").is_some()); + assert!(registry.get("azure.resource.network").is_some()); + + // List all names + let names = registry.list_names(); + assert_eq!(names.len(), 3); + assert!(names.contains(&"azure.resource.vm".into())); + assert!(names.contains(&"azure.resource.storage".into())); + assert!(names.contains(&"azure.resource.network".into())); +} + +#[test] +fn test_global_resource_registry() { + let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); + + // Clear registry + resource::clear(); + + // Register Azure Resource schemas + let vm_schema = create_vm_resource_schema(); + let storage_schema = create_storage_resource_schema(); + let network_schema = create_network_resource_schema(); + + assert!(resource::register("azure.resource.vm", vm_schema).is_ok()); + assert!(resource::register("azure.resource.storage", storage_schema).is_ok()); + assert!(resource::register("azure.resource.network", network_schema).is_ok()); + + // Verify all are registered in global registry + assert_eq!(resource::len(), 3); + assert!(resource::contains("azure.resource.vm")); + assert!(resource::contains("azure.resource.storage")); + assert!(resource::contains("azure.resource.network")); + + // Test retrieval from global registry + let retrieved_vm = resource::get("azure.resource.vm"); + let retrieved_storage = resource::get("azure.resource.storage"); + let retrieved_network = resource::get("azure.resource.network"); + + assert!(retrieved_vm.is_some()); + assert!(retrieved_storage.is_some()); + assert!(retrieved_network.is_some()); + + // Clean up + resource::clear(); +} + +#[test] +fn test_resource_schema_validation_patterns() { + let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); + + let registry = SchemaRegistry::new(); + + // 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 = registry.register("azure.template.arm", schema_rc); + assert!(result.is_ok()); + assert!(registry.contains("azure.template.arm")); +} + +#[test] +fn test_resource_schema_with_invalid_names() { + let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); + + let registry = SchemaRegistry::new(); + let resource_schema = create_resource_schema(); + + // Test invalid names + assert!(registry.register("", resource_schema.clone()).is_err()); + assert!(registry.register(" ", resource_schema.clone()).is_err()); + assert!(registry.register("\t", resource_schema.clone()).is_err()); + assert!(registry.register("\n", resource_schema).is_err()); + + // Verify registry is empty + assert!(registry.is_empty()); +} + +#[test] +fn test_resource_schema_duplicate_registration() { + let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); + + let registry = SchemaRegistry::new(); + let vm_schema = create_vm_resource_schema(); + + // First registration should succeed + assert!(registry + .register("azure.resource.vm", vm_schema.clone()) + .is_ok()); + assert_eq!(registry.len(), 1); + + // Duplicate registration should fail + let duplicate_result = registry.register("azure.resource.vm", 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"); + } + _ => panic!("Expected AlreadyExists error"), + } + + // Registry should still have only one entry + assert_eq!(registry.len(), 1); +} + +#[test] +fn test_azure_resource_removal() { + let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); + + let registry = SchemaRegistry::new(); + + // Register multiple Azure Resource schemas + let resources = vec![ + ("azure.resource.vm", create_vm_resource_schema()), + ("azure.resource.storage", create_storage_resource_schema()), + ("azure.resource.network", create_network_resource_schema()), + ]; + + for (name, schema) in &resources { + assert!(registry.register(*name, schema.clone()).is_ok()); + } + + assert_eq!(registry.len(), 3); + + // Remove one resource + let removed = registry.remove("azure.resource.storage"); + assert!(removed.is_some()); + assert_eq!(registry.len(), 2); + assert!(!registry.contains("azure.resource.storage")); + + // 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!(registry.contains("azure.resource.vm")); + assert!(registry.contains("azure.resource.network")); +} + +#[test] +#[cfg(feature = "std")] +fn test_concurrent_resource_schema_access() { + let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); + + use std::sync::Barrier; + use std::thread; + + // Create isolated registry for this test + let test_registry = Rc::new(SchemaRegistry::new()); + let barrier = Rc::new(Barrier::new(3)); + let mut handles = vec![]; + + // Test concurrent registration of different Azure Resource schemas + let resources = [ + "azure.resource.vm", + "azure.resource.storage", + "azure.resource.network", + ]; + + for (i, resource_name) in resources.iter().enumerate() { + let barrier = Rc::clone(&barrier); + let registry = Rc::clone(&test_registry); + let name: String = (*resource_name).into(); + + let handle: thread::JoinHandle> = + 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(); + 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 3 resource schemas registered + assert_eq!(test_registry.len(), 3); + assert!(test_registry.contains("azure.resource.vm")); + assert!(test_registry.contains("azure.resource.storage")); + assert!(test_registry.contains("azure.resource.network")); +} + +#[test] +fn test_azure_resource_clear() { + let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); + + let registry = SchemaRegistry::new(); + + // Register multiple Azure Resource schemas + assert!(registry + .register("azure.resource.vm", create_vm_resource_schema()) + .is_ok()); + assert!(registry + .register("azure.resource.storage", create_storage_resource_schema()) + .is_ok()); + assert!(registry + .register("azure.resource.network", create_network_resource_schema()) + .is_ok()); + + assert_eq!(registry.len(), 3); + assert!(!registry.is_empty()); + + // Clear all resources + registry.clear(); + + assert_eq!(registry.len(), 0); + assert!(registry.is_empty()); + assert!(registry.list_names().is_empty()); + + // Verify specific resources are no longer present + assert!(!registry.contains("azure.resource.vm")); + assert!(!registry.contains("azure.resource.storage")); + assert!(!registry.contains("azure.resource.network")); +} + +#[test] +fn test_resource_type_validation() { + let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); + + let registry = SchemaRegistry::new(); + + // Test different Azure resource types with specific naming patterns + let resource_types = vec![ + "azure.compute.vm", + "azure.storage.account", + "azure.network.vnet", + "azure.keyvault.vault", + "azure.sql.database", + "azure.webapp.site", + ]; + + let basic_schema = create_resource_schema(); + + // Register all resource types + for resource_type in &resource_types { + let result = registry.register(*resource_type, basic_schema.clone()); + assert!(result.is_ok(), "Failed to register {resource_type}"); + } + + // Verify all are registered + assert_eq!(registry.len(), resource_types.len()); + + for resource_type in &resource_types { + assert!(registry.contains(resource_type), "Missing {resource_type}"); + assert!( + registry.get(resource_type).is_some(), + "Cannot retrieve {resource_type}" + ); + } + + // Verify list contains all types + let names = registry.list_names(); + assert_eq!(names.len(), resource_types.len()); + + for resource_type in &resource_types { + assert!( + names.contains(&(*resource_type).into()), + "Name list missing {resource_type}" + ); + } +} + +// Schema validation tests for Azure Resource schemas + +#[test] +fn test_validate_vm_resource_valid() { + let schema = create_vm_resource_schema(); + + let valid_vm_data = json!({ + "type": "Microsoft.Compute/virtualMachines", + "apiVersion": "2021-03-01", + "name": "my-vm-01", + "location": "eastus", + "properties": { + "hardwareProfile": { + "vmSize": "Standard_B2s" + }, + "osProfile": { + "computerName": "my-computer", + "adminUsername": "azureuser" + } + } + }); + + let value = Value::from(valid_vm_data); + let result = SchemaValidator::validate(&value, &schema); + assert!(result.is_ok()); +} + +#[test] +fn test_validate_vm_resource_missing_required() { + let schema = create_vm_resource_schema(); + + let invalid_vm_data = json!({ + "type": "Microsoft.Compute/virtualMachines", + "apiVersion": "2021-03-01", + "name": "my-vm-01" + // Missing location and properties + }); + + let value = Value::from(invalid_vm_data); + let result = SchemaValidator::validate(&value, &schema); + assert!(result.is_err()); + + match result.unwrap_err() { + ValidationError::MissingRequiredProperty { property, .. } => { + // Should be missing either location or properties + assert!(property == "location".into() || property == "properties".into()); + } + other => panic!("Expected MissingRequiredProperty error, got: {:?}", other), + } +} + +#[test] +fn test_validate_vm_resource_invalid_vm_size() { + let schema = create_vm_resource_schema(); + + let invalid_vm_data = json!({ + "type": "Microsoft.Compute/virtualMachines", + "apiVersion": "2021-03-01", + "name": "my-vm-01", + "location": "eastus", + "properties": { + "hardwareProfile": { + "vmSize": "InvalidSize" + }, + "osProfile": { + "computerName": "my-computer", + "adminUsername": "azureuser" + } + } + }); + + let value = Value::from(invalid_vm_data); + let result = SchemaValidator::validate(&value, &schema); + assert!(result.is_err()); + + match result.unwrap_err() { + ValidationError::PropertyValidationFailed { + property, error, .. + } => { + assert_eq!(property, "properties".into()); + match error.as_ref() { + ValidationError::PropertyValidationFailed { + property: inner_prop, + error: inner_error, + .. + } => { + assert_eq!(*inner_prop, "hardwareProfile".into()); + match inner_error.as_ref() { + ValidationError::PropertyValidationFailed { + property: vm_size_prop, + error: vm_size_error, + .. + } => { + assert_eq!(*vm_size_prop, "vmSize".into()); + match vm_size_error.as_ref() { + ValidationError::NotInEnum { .. } => { + // Expected deeply nested error structure + } + other => { + panic!("Expected NotInEnum error for vmSize, got: {:?}", other) + } + } + } + other => panic!( + "Expected PropertyValidationFailed for vmSize, got: {:?}", + other + ), + } + } + other => panic!( + "Expected PropertyValidationFailed for hardwareProfile, got: {:?}", + other + ), + } + } + other => panic!("Expected PropertyValidationFailed error, got: {:?}", other), + } +} + +#[test] +fn test_validate_storage_resource_valid() { + let schema = create_storage_resource_schema(); + + let valid_storage_data = json!({ + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2021-04-01", + "name": "mystorageaccount001", + "location": "westus2", + "sku": { + "name": "Standard_LRS" + }, + "kind": "StorageV2", + "properties": { + "accessTier": "Hot", + "encryption": { + "services": {} + } + } + }); + + let value = Value::from(valid_storage_data); + let result = SchemaValidator::validate(&value, &schema); + assert!(result.is_ok()); +} + +#[test] +fn test_validate_storage_resource_invalid_name() { + let schema = create_storage_resource_schema(); + + let invalid_storage_data = json!({ + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2021-04-01", + "name": "Invalid-Storage-Name-With-Caps-And-Dashes", + "location": "westus2", + "sku": { + "name": "Standard_LRS" + }, + "kind": "StorageV2" + }); + + let value = Value::from(invalid_storage_data); + let result = SchemaValidator::validate(&value, &schema); + assert!(result.is_err()); + + match result.unwrap_err() { + ValidationError::PropertyValidationFailed { + property, error, .. + } => { + assert_eq!(property, "name".into()); + match error.as_ref() { + ValidationError::PatternMismatch { .. } => { + // Expected pattern mismatch for storage account name + } + other => panic!("Expected PatternMismatch error for name, got: {:?}", other), + } + } + other => panic!("Expected PropertyValidationFailed error, got: {:?}", other), + } +} + +#[test] +fn test_validate_storage_resource_invalid_sku() { + let schema = create_storage_resource_schema(); + + let invalid_storage_data = json!({ + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2021-04-01", + "name": "mystorageaccount001", + "location": "westus2", + "sku": { + "name": "Invalid_SKU" + }, + "kind": "StorageV2" + }); + + let value = Value::from(invalid_storage_data); + let result = SchemaValidator::validate(&value, &schema); + assert!(result.is_err()); + + match result.unwrap_err() { + ValidationError::PropertyValidationFailed { + property, error, .. + } => { + assert_eq!(property, "sku".into()); + match error.as_ref() { + ValidationError::PropertyValidationFailed { + property: sku_prop, + error: sku_error, + .. + } => { + assert_eq!(*sku_prop, "name".into()); + match sku_error.as_ref() { + ValidationError::NotInEnum { .. } => { + // Expected enum validation error + } + other => panic!("Expected NotInEnum error for sku name, got: {:?}", other), + } + } + other => panic!( + "Expected PropertyValidationFailed for sku name, got: {:?}", + other + ), + } + } + other => panic!("Expected PropertyValidationFailed error, got: {:?}", other), + } +} + +#[test] +fn test_validate_network_resource_valid() { + let schema = create_network_resource_schema(); + + let valid_network_data = json!({ + "type": "Microsoft.Network/virtualNetworks", + "apiVersion": "2021-02-01", + "name": "my-vnet", + "location": "eastus", + "properties": { + "addressSpace": { + "addressPrefixes": ["10.0.0.0/16"] + }, + "subnets": [ + { + "name": "default", + "properties": { + "addressPrefix": "10.0.1.0/24" + } + } + ] + } + }); + + let value = Value::from(valid_network_data); + let result = SchemaValidator::validate(&value, &schema); + assert!(result.is_ok()); +} + +#[test] +fn test_validate_network_resource_invalid_address_prefix() { + let schema = create_network_resource_schema(); + + let invalid_network_data = json!({ + "type": "Microsoft.Network/virtualNetworks", + "apiVersion": "2021-02-01", + "name": "my-vnet", + "location": "eastus", + "properties": { + "addressSpace": { + "addressPrefixes": ["invalid-cidr"] + } + } + }); + + let value = Value::from(invalid_network_data); + let result = SchemaValidator::validate(&value, &schema); + assert!(result.is_err()); + + match result.unwrap_err() { + ValidationError::PropertyValidationFailed { + property, error, .. + } => { + assert_eq!(property, "properties".into()); + match error.as_ref() { + ValidationError::PropertyValidationFailed { + property: addr_prop, + .. + } => { + assert_eq!(*addr_prop, "addressSpace".into()); + // Continue checking nested structure for array validation + } + other => panic!( + "Expected PropertyValidationFailed for addressSpace, got: {:?}", + other + ), + } + } + other => panic!("Expected PropertyValidationFailed error, got: {:?}", other), + } +} + +#[test] +fn test_validate_basic_resource_enum() { + let schema = create_resource_schema(); + + // Test all valid resource types + let valid_types = [ + "Microsoft.Compute/virtualMachines", + "Microsoft.Storage/storageAccounts", + "Microsoft.Network/virtualNetworks", + ]; + + for resource_type in valid_types { + let value = Value::from(resource_type); + let result = SchemaValidator::validate(&value, &schema); + assert!( + result.is_ok(), + "Resource type '{resource_type}' should be valid" + ); + } + + // Test invalid resource type + let invalid_value = Value::from("Microsoft.Invalid/resourceType"); + 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_arm_template() { + let complex_schema_json = json!({ + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "contentVersion": { + "type": "string" + }, + "parameters": { + "type": "object", + "additionalProperties": { "type": "any" } + }, + "variables": { + "type": "object", + "additionalProperties": { "type": "any" } + }, + "resources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "apiVersion": { + "type": "string" + }, + "name": { + "type": "string" + }, + "location": { + "type": "string" + }, + "properties": { + "type": "object", + "additionalProperties": { "type": "any" } + }, + "tags": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "required": ["type", "apiVersion", "name"], + "additionalProperties": { "type": "any" } + } + }, + "outputs": { + "type": "object", + "additionalProperties": { "type": "any" } + } + }, + "required": ["resources"], + "additionalProperties": { "type": "any" } + }); + + let schema = Schema::from_serde_json_value(complex_schema_json).unwrap(); + + let valid_template_data = json!({ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "vmName": { + "type": "string", + "defaultValue": "myVM" + } + }, + "variables": { + "storageAccountName": "[concat('storage', uniqueString(resourceGroup().id))]" + }, + "resources": [ + { + "type": "Microsoft.Compute/virtualMachines", + "apiVersion": "2021-03-01", + "name": "[parameters('vmName')]", + "location": "[resourceGroup().location]", + "properties": { + "hardwareProfile": { + "vmSize": "Standard_B1s" + } + }, + "tags": { + "environment": "dev", + "project": "test" + } + } + ], + "outputs": { + "vmId": { + "type": "string", + "value": "[resourceId('Microsoft.Compute/virtualMachines', parameters('vmName'))]" + } + } + }); + + let value = Value::from(valid_template_data); + let result = SchemaValidator::validate(&value, &schema); + assert!(result.is_ok()); +} + +#[test] +fn test_validate_resource_type_mismatch() { + let schema = create_vm_resource_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), + } +} + +#[test] +fn test_complex_nested_azure_template_validation() { + let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); + + // Create a complex ARM template schema with deeply nested properties + let complex_schema = json!({ + "type": "object", + "properties": { + "$schema": { "type": "string" }, + "contentVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "metadata": { + "type": "object", + "properties": { + "description": { "type": "string" }, + "author": { "type": "string" }, + "tags": { + "type": "object", + "additionalProperties": { "type": "string" } + } + } + }, + "parameters": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "type": { "enum": ["string", "int", "bool", "array", "object"] }, + "defaultValue": { "type": "any" }, + "allowedValues": { "type": "array", "items": { "type": "any" } }, + "minValue": { "type": "number" }, + "maxValue": { "type": "number" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" }, + "metadata": { + "type": "object", + "properties": { + "description": { "type": "string" }, + "strongType": { "type": "string" } + } + } + }, + "required": ["type"] + } + }, + "variables": { "type": "object" }, + "resources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { "type": "string" }, + "apiVersion": { "type": "string" }, + "name": { "type": "string" }, + "location": { "type": "string" }, + "dependsOn": { + "type": "array", + "items": { "type": "string" } + }, + "condition": { "type": "boolean" }, + "copy": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "count": { "type": "integer", "minimum": 1, "maximum": 800 }, + "mode": { "enum": ["Parallel", "Serial"] }, + "batchSize": { "type": "integer", "minimum": 1 } + }, + "required": ["name", "count"] + }, + "properties": { "type": "object" }, + "tags": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "required": ["type", "apiVersion", "name"] + } + }, + "outputs": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "type": { "enum": ["string", "int", "bool", "array", "object"] }, + "value": { "type": "any" }, + "condition": { "type": "boolean" }, + "copy": { + "type": "object", + "properties": { + "count": { "type": "integer", "minimum": 1 }, + "input": { "type": "any" } + }, + "required": ["count", "input"] + } + }, + "required": ["type", "value"] + } + } + }, + "required": ["$schema", "contentVersion", "resources"] + }); + + let schema = Schema::from_serde_json_value(complex_schema).unwrap(); + + // Valid complex template with nested copy loops and conditions + let valid_template = json!({ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.2.3.4", + "metadata": { + "description": "Complex deployment with copy loops and conditions", + "author": "Azure DevOps Team", + "tags": { + "environment": "production", + "cost-center": "engineering", + "department": "cloud-infrastructure" + } + }, + "parameters": { + "vmCount": { + "type": "int", + "defaultValue": 3, + "minValue": 1, + "maxValue": 10, + "metadata": { + "description": "Number of VMs to deploy", + "strongType": "Microsoft.Compute/SKUs" + } + }, + "environment": { + "type": "string", + "defaultValue": "dev", + "allowedValues": ["dev", "test", "staging", "prod"], + "metadata": { + "description": "Environment name for resource naming" + } + }, + "enableMonitoring": { + "type": "bool", + "defaultValue": true, + "metadata": { + "description": "Whether to enable monitoring extensions" + } + } + }, + "variables": { + "vmPrefix": "[concat(parameters('environment'), '-vm-')]", + "storageAccountName": "[concat('storage', uniqueString(resourceGroup().id))]" + }, + "resources": [ + { + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2021-04-01", + "name": "[variables('storageAccountName')]", + "location": "eastus", + "properties": { + "accountType": "Standard_LRS" + }, + "tags": { + "purpose": "vm-diagnostics", + "environment": "[parameters('environment')]" + } + }, + { + "type": "Microsoft.Compute/virtualMachines", + "apiVersion": "2021-03-01", + "name": "[concat(variables('vmPrefix'), copyIndex(1))]", + "location": "eastus", + "condition": true, + "dependsOn": [ + "[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))]" + ], + "copy": { + "name": "vmLoop", + "count": 5, + "mode": "Parallel", + "batchSize": 2 + }, + "properties": { + "hardwareProfile": { + "vmSize": "Standard_B2s" + } + }, + "tags": { + "environment": "[parameters('environment')]", + "vm-index": "[string(copyIndex())]" + } + } + ], + "outputs": { + "storageAccountId": { + "type": "string", + "value": "[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))]" + }, + "vmIds": { + "type": "array", + "copy": { + "count": 5, + "input": "[resourceId('Microsoft.Compute/virtualMachines', concat(variables('vmPrefix'), copyIndex(1)))]" + }, + "value" : "[resourceId('Microsoft.Compute/virtualMachines', concat(variables('vmPrefix'), copyIndex(1)))]" + } + } + }); + + let value = Value::from(valid_template); + let result = SchemaValidator::validate(&value, &schema); + std::dbg!(&result); + assert!( + result.is_ok(), + "Complex valid template should pass validation" + ); + + // Test invalid template with constraint violations + let invalid_template = json!({ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "invalid-version", // Should match pattern + "resources": [ + { + "type": "Microsoft.Compute/virtualMachines", + "apiVersion": "2021-03-01", + "name": "test-vm", + "copy": { + "name": "vmLoop", + "count": 1000 // Exceeds maximum of 800 + } + } + ] + }); + + let value = Value::from(invalid_template); + let result = SchemaValidator::validate(&value, &schema); + assert!( + result.is_err(), + "Template with constraint violations should fail" + ); +} + +#[test] +fn test_deep_nesting_and_recursive_structures() { + let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); + + // Schema with moderate nesting (5 levels) to avoid macro recursion limits + let deep_schema = json!({ + "type": "object", + "properties": { + "level1": { + "type": "object", + "properties": { + "level2": { + "type": "object", + "properties": { + "level3": { + "type": "object", + "properties": { + "level4": { + "type": "object", + "properties": { + "level5": { + "type": "object", + "properties": { + "deepValue": { + "type": "string", + "pattern": "^deep-[0-9]+$" + }, + "recursiveArray": { + "type": "array", + "items": { + "type": "object", + "properties": { + "nested": { + "type": "object", + "properties": { + "value": { "type": "number" }, + "metadata": { + "type": "object", + "additionalProperties": { "type": "string" } + } + } + } + } + } + } + }, + "required": ["deepValue"] + } + } + } + } + } + } + } + } + } + }, + "required": ["level1"] + }); + + let schema = Schema::from_serde_json_value(deep_schema).unwrap(); + + // Valid deeply nested structure + let valid_deep_data = json!({ + "level1": { + "level2": { + "level3": { + "level4": { + "level5": { + "deepValue": "deep-12345", + "recursiveArray": [ + { + "nested": { + "value": 42.5, + "metadata": { + "type": "numeric", + "unit": "percentage", + "source": "sensor-1" + } + } + }, + { + "nested": { + "value": 15.3, + "metadata": { + "type": "numeric", + "unit": "temperature", + "source": "sensor-2" + } + } + } + ] + } + } + } + } + } + }); + + let value = Value::from(valid_deep_data); + let result = SchemaValidator::validate(&value, &schema); + assert!(result.is_ok(), "Valid deeply nested structure should pass"); + + // Invalid - missing required deepValue + let invalid_deep_data = json!({ + "level1": { + "level2": { + "level3": { + "level4": { + "level5": { + // Missing required "deepValue" + "recursiveArray": [] + } + } + } + } + } + }); + + let value = Value::from(invalid_deep_data); + let result = SchemaValidator::validate(&value, &schema); + assert!( + result.is_err(), + "Structure missing required deep field should fail" + ); +} + +#[test] +fn test_unicode_and_internationalization_validation() { + let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); + + // Schema supporting international characters and Unicode + let unicode_schema = json!({ + "type": "object", + "properties": { + "names": { + "type": "object", + "properties": { + "chinese": { "type": "string", "pattern": "^[\\u4e00-\\u9fff]+$" }, + "russian": { "type": "string", "pattern": "^[\\u0400-\\u04ff]+$" }, + "japanese": { "type": "string", "pattern": "^[\\u3040-\\u309f\\u30a0-\\u30ff\\u4e00-\\u9fff]+$" }, + "korean": { "type": "string", "pattern": "^[\\uac00-\\ud7af]+$" }, + "hindi": { "type": "string", "pattern": "^[\\u0900-\\u097f]+$" }, + "french": { "type": "string", "pattern": "^[a-zA-ZàâäéèêëïîôöùûüÿñæœÀÂÄÉÈÊËÏÎÔÖÙÛÜŸÑÆŒ\\s-]+$" } + } + }, + "descriptions": { + "type": "object", + "additionalProperties": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + } + }, + "metadata": { + "type": "object", + "properties": { + "encoding": { "enum": ["UTF-8", "UTF-16", "UTF-32"] }, + "locale": { "type": "string", "pattern": "^[a-z]{2}-[A-Z]{2}$" }, + "timezone": { "type": "string" } + } + } + }, + "required": ["names", "metadata"] + }); + + let schema = Schema::from_serde_json_value(unicode_schema).unwrap(); + + // Valid international data + let valid_unicode_data = json!({ + "names": { + "chinese": "你好世界", + "russian": "привет", + "japanese": "こんにちは世界", + "korean": "안녕하세요", + "hindi": "नमस्ते", + "french": "Bonjour le Monde" + }, + "descriptions": { + "en-US": "Hello World application for international users", + "zh-CN": "面向国际用户的你好世界应用程序", + "ru-RU": "Приложение Hello World для международных пользователей", + "ja-JP": "国際ユーザー向けのHello Worldアプリケーション", + "ko-KR": "국제 사용자를 위한 Hello World 애플리케이션", + "hi-IN": "अंतर्राष्ट्रीय उपयोगकर्ताओं के लिए हैलो वर्ल्ड एप्लिकेशन", + "fr-FR": "Application Hello World pour les utilisateurs internationaux" + }, + "metadata": { + "encoding": "UTF-8", + "locale": "en-US", + "timezone": "UTC" + } + }); + + let value = Value::from(valid_unicode_data); + let result = SchemaValidator::validate(&value, &schema); + assert!(result.is_ok(), "Valid Unicode data should pass validation"); + + // Invalid - non-matching Unicode patterns + let invalid_unicode_data = json!({ + "names": { + "chinese": "Hello", // Should be Chinese characters + "russian": "Goodbye", // Should be Russian characters + "japanese": "Test", // Should be Japanese characters + "korean": "Invalid", // Should be Korean characters + "hindi": "Wrong", // Should be Hindi characters + "french": "123456" // Should be French text + }, + "metadata": { + "encoding": "UTF-8", + "locale": "invalid-locale", // Should match pattern + "timezone": "UTC" + } + }); + + let value = Value::from(invalid_unicode_data); + let result = SchemaValidator::validate(&value, &schema); + assert!( + result.is_err(), + "Invalid Unicode patterns should fail validation" + ); +} + +#[test] +fn test_edge_cases_and_boundary_conditions() { + let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); + + // Schema with strict boundary conditions + let boundary_schema = json!({ + "type": "object", + "properties": { + "strings": { + "type": "object", + "properties": { + "empty": { "type": "string", "minLength": 0, "maxLength": 0 }, + "single": { "type": "string", "minLength": 1, "maxLength": 1 }, + "exact_length": { "type": "string", "minLength": 10, "maxLength": 10 }, + "very_long": { "type": "string", "maxLength": 10000 } + } + }, + "numbers": { + "type": "object", + "properties": { + "zero": { "type": "number", "minimum": 0, "maximum": 0 }, + "negative": { "type": "number", "minimum": -1000, "maximum": -1 }, + "positive": { "type": "number", "minimum": 1, "maximum": 1000 }, + "float_precision": { "type": "number" } + } + }, + "arrays": { + "type": "object", + "properties": { + "empty": { "type": "array", "minItems": 0, "maxItems": 0, "items": { "type": "string" } }, + "single_item": { "type": "array", "minItems": 1, "maxItems": 1, "items": { "type": "string" } }, + "exact_size": { "type": "array", "minItems": 5, "maxItems": 5, "items": { "type": "integer" } }, + "large_array": { "type": "array", "maxItems": 1000, "items": { "type": "boolean" } } + } + }, + "objects": { + "type": "object", + "properties": { + "empty": { "type": "object", "additionalProperties": false }, + "single_prop": { + "type": "object", + "properties": { "only": { "type": "string" } }, + "additionalProperties": false, + "required": ["only"] + } + } + }, + "nulls_and_optionals": { + "type": "object", + "properties": { + "nullable": { "type": "string" }, + "optional": { "type": "string" }, + "required_null": { "type": "null" } + }, + "required": ["required_null"] + } + }, + "required": ["strings", "numbers", "arrays", "objects", "nulls_and_optionals"] + }); + + let schema = Schema::from_serde_json_value(boundary_schema).unwrap(); + + // Valid boundary condition data + let valid_boundary_data = json!({ + "strings": { + "empty": "", + "single": "a", + "exact_length": "exactly_10", + "very_long": "a".repeat(9999) + }, + "numbers": { + "zero": 0, + "negative": -500, + "positive": 250, + "float_precision": 123.45 + }, + "arrays": { + "empty": [], + "single_item": ["test"], + "exact_size": [1, 2, 3, 4, 5], + "large_array": vec![true; 500] + }, + "objects": { + "empty": {}, + "single_prop": { + "only": "value" + } + }, + "nulls_and_optionals": { + "nullable": "string_value", + "optional": "present", + "required_null": null + } + }); + + let value = Value::from(valid_boundary_data); + let result = SchemaValidator::validate(&value, &schema); + assert!( + result.is_ok(), + "Valid boundary conditions should pass validation" + ); + + // Invalid boundary violations + let invalid_boundary_data = json!({ + "strings": { + "empty": "not empty", // Should be empty + "single": "too long", // Should be exactly 1 character + "exact_length": "wrong", // Should be exactly 10 characters + "very_long": "a".repeat(10001) // Exceeds maximum length + }, + "numbers": { + "zero": 0.1, // Should be exactly 0 + "negative": 1, // Should be negative + "positive": -1, // Should be positive + "float_precision": 123.456 // Wrong precision + }, + "arrays": { + "empty": ["not empty"], // Should be empty + "single_item": [], // Should have exactly 1 item + "exact_size": [1, 2, 3], // Should have exactly 5 items + "large_array": vec![true; 1001] // Exceeds maximum items + }, + "objects": { + "empty": { "should_be_empty": true }, // Should have no properties + "single_prop": {} // Missing required property + }, + "nulls_and_optionals": { + "nullable": "should allow null or string", + "required_null": "should be null" // Should be null + } + }); + + let value = Value::from(invalid_boundary_data); + let result = SchemaValidator::validate(&value, &schema); + assert!( + result.is_err(), + "Boundary violations should fail validation" + ); +} + +#[test] +fn test_concurrent_schema_validation_stress() { + use std::sync::Arc; + use std::thread; + + let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); + + // Create a complex schema for concurrent testing + let concurrent_schema = json!({ + "type": "object", + "properties": { + "id": { "type": "string", "pattern": "^[a-zA-Z0-9-]{8,64}$" }, + "timestamp": { "type": "string" }, + "data": { + "type": "object", + "properties": { + "values": { + "type": "array", + "items": { "type": "number" }, + "minItems": 1, + "maxItems": 100 + }, + "metadata": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "required": ["values"] + } + }, + "required": ["id", "timestamp", "data"] + }); + + let schema = Arc::new(Schema::from_serde_json_value(concurrent_schema).unwrap()); + + // Spawn multiple threads to validate concurrently + let mut handles = vec![]; + + for thread_id in 0..10 { + let schema_clone = Arc::clone(&schema); + + let handle = thread::spawn(move || { + let mut results = Vec::new(); + + for i in 0..100 { + let test_data = json!({ + "id": format!("thread-{}-item-{}", thread_id, i), + "timestamp": "2023-12-31T23:59:59Z", + "data": { + "values": vec![1.0, 2.0, 3.0, (i as f64)], + "metadata": { + "thread": thread_id.to_string(), + "iteration": i.to_string(), + "test_type": "concurrent" + } + } + }); + + let value = Value::from(test_data); + let result = SchemaValidator::validate(&value, &schema_clone); + results.push(result.is_ok()); + } + + results + }); + + handles.push(handle); + } + + // Wait for all threads and collect results + let mut all_results = Vec::new(); + for handle in handles { + let thread_results = handle.join().expect("Thread should complete successfully"); + all_results.extend(thread_results); + } + + // All validations should pass + let successful_validations = all_results.iter().filter(|&&result| result).count(); + assert_eq!( + successful_validations, 1000, + "All 1000 concurrent validations should pass" + ); +} From 8e012e806a2bdb3c6727a804699a6aa8dfc1d2a7 Mon Sep 17 00:00:00 2001 From: Anand Krishnamoorthi Date: Wed, 13 Aug 2025 10:33:15 -0500 Subject: [PATCH 2/2] feat: Complete target system with C# bindings and resource inference - Add comprehensive target system with TargetRegistry and target-aware compilation - Implement resource type inference from policy equality expressions - Create modular C# bindings with separate wrapper classes for each concept - Add thread-safe CompiledPolicy with reference counting for safe disposal - Enhance FFI with detailed error propagation and target functionality - Create TargetExampleApp demonstrating Azure Policy integration - Add CI/CD pipeline testing for all C# applications - Support target definitions with schema validation and resource selectors - Implement PolicyModule struct and target-aware compilation methods - Add comprehensive test coverage for target functionality Signed-off-by: Anand Krishnamoorthi --- .github/workflows/test-csharp.yml | 30 +- Cargo.lock | 101 +- Cargo.toml | 1 + bindings/c-nostd/main.c | 14 +- bindings/c/main.c | 20 +- bindings/cpp/regorus.hpp | 4 +- bindings/csharp/API.md | 421 ++++ bindings/csharp/Regorus/CompiledPolicy.cs | 168 ++ bindings/csharp/Regorus/Compiler.cs | 196 ++ .../csharp/Regorus/{Regorus.cs => Engine.cs} | 2 +- bindings/csharp/Regorus/NativeMethods.cs | 546 +++++ bindings/csharp/Regorus/PolicyInfo.cs | 141 ++ bindings/csharp/Regorus/Regorus.csproj | 8 +- bindings/csharp/Regorus/RegorusFFI.cs | 244 --- bindings/csharp/Regorus/SchemaRegistry.cs | 284 +++ bindings/csharp/Regorus/TargetRegistry.cs | 185 ++ bindings/csharp/TargetExampleApp/Program.cs | 291 +++ .../TargetExampleApp/TargetExampleApp.csproj | 26 + .../TargetExampleApp/azure_policy.target.json | 125 ++ bindings/csharp/global.json | 2 +- bindings/ffi/Cargo.lock | 57 +- bindings/ffi/Cargo.toml | 1 - bindings/ffi/build.rs | 9 - bindings/ffi/src/allocator.rs | 31 + bindings/ffi/src/common.rs | 229 ++ bindings/ffi/src/compile.rs | 208 ++ bindings/ffi/src/compiled_policy.rs | 69 + bindings/ffi/src/effect_registry.rs | 175 ++ bindings/ffi/src/engine.rs | 454 ++++ bindings/ffi/src/lib.rs | 558 +---- bindings/ffi/src/schema_registry.rs | 178 ++ bindings/ffi/src/target_registry.rs | 107 + bindings/go/pkg/regorus/mod.go | 34 +- bindings/java/Cargo.lock | 37 +- bindings/python/Cargo.lock | 33 +- bindings/ruby/Cargo.lock | 33 +- bindings/wasm/Cargo.lock | 33 +- src/ast.rs | 3 + src/builtins/strings.rs | 18 +- src/compile.rs | 91 + src/compiled_policy.rs | 215 ++ src/engine.rs | 320 ++- src/interpreter.rs | 112 +- src/interpreter/error.rs | 58 + src/interpreter/target/infer.rs | 278 +++ src/interpreter/target/resolve.rs | 248 +++ src/lib.rs | 23 +- src/parser.rs | 57 + src/policy_info.rs | 42 + src/registry.rs | 56 + src/registry/tests/target.rs | 1254 +++++++++++ src/schema.rs | 32 +- src/schema/registry.rs | 224 -- src/schema/tests.rs | 3 - src/schema/tests/effect.rs | 970 --------- src/schema/tests/registry.rs | 503 ----- src/schema/tests/resource.rs | 1878 ----------------- src/target.rs | 79 + src/target/deserialize.rs | 76 + src/target/error.rs | 35 + src/target/resource_schema_selector.rs | 90 + src/target/tests/deserialize.rs | 358 ++++ src/tests/interpreter/mod.rs | 218 +- tests/ensure_no_std/src/main.rs | 5 +- .../cases/builtins/strings/sprintf.yaml | 80 + .../cases/target/azure_policy.yaml | 79 + tests/interpreter/cases/target/basic.yaml | 682 ++++++ tests/interpreter/cases/target/complex.yaml | 561 +++++ .../target/definitions/azure_compute.json | 47 + .../target/definitions/azure_policy.json | 125 ++ .../target/definitions/complex_target.json | 195 ++ .../cases/target/definitions/msgraph.json | 189 ++ .../definitions/no_default_schema_target.json | 21 + .../target/definitions/sample_target.json | 38 + tests/interpreter/cases/target/msgraph.yaml | 83 + .../cases/target/resource_type_inference.yaml | 455 ++++ 76 files changed, 10278 insertions(+), 4578 deletions(-) create mode 100644 bindings/csharp/API.md create mode 100644 bindings/csharp/Regorus/CompiledPolicy.cs create mode 100644 bindings/csharp/Regorus/Compiler.cs rename bindings/csharp/Regorus/{Regorus.cs => Engine.cs} (99%) create mode 100644 bindings/csharp/Regorus/NativeMethods.cs create mode 100644 bindings/csharp/Regorus/PolicyInfo.cs delete mode 100644 bindings/csharp/Regorus/RegorusFFI.cs create mode 100644 bindings/csharp/Regorus/SchemaRegistry.cs create mode 100644 bindings/csharp/Regorus/TargetRegistry.cs create mode 100644 bindings/csharp/TargetExampleApp/Program.cs create mode 100644 bindings/csharp/TargetExampleApp/TargetExampleApp.csproj create mode 100644 bindings/csharp/TargetExampleApp/azure_policy.target.json create mode 100644 bindings/ffi/src/allocator.rs create mode 100644 bindings/ffi/src/common.rs create mode 100644 bindings/ffi/src/compile.rs create mode 100644 bindings/ffi/src/compiled_policy.rs create mode 100644 bindings/ffi/src/effect_registry.rs create mode 100644 bindings/ffi/src/engine.rs create mode 100644 bindings/ffi/src/schema_registry.rs create mode 100644 bindings/ffi/src/target_registry.rs create mode 100644 src/compile.rs create mode 100644 src/compiled_policy.rs create mode 100644 src/interpreter/error.rs create mode 100644 src/interpreter/target/infer.rs create mode 100644 src/interpreter/target/resolve.rs create mode 100644 src/policy_info.rs create mode 100644 src/registry/tests/target.rs delete mode 100644 src/schema/registry.rs delete mode 100644 src/schema/tests/effect.rs delete mode 100644 src/schema/tests/registry.rs delete mode 100644 src/schema/tests/resource.rs create mode 100644 src/target.rs create mode 100644 src/target/deserialize.rs create mode 100644 src/target/error.rs create mode 100644 src/target/resource_schema_selector.rs create mode 100644 src/target/tests/deserialize.rs create mode 100644 tests/interpreter/cases/builtins/strings/sprintf.yaml create mode 100644 tests/interpreter/cases/target/azure_policy.yaml create mode 100644 tests/interpreter/cases/target/basic.yaml create mode 100644 tests/interpreter/cases/target/complex.yaml create mode 100644 tests/interpreter/cases/target/definitions/azure_compute.json create mode 100644 tests/interpreter/cases/target/definitions/azure_policy.json create mode 100644 tests/interpreter/cases/target/definitions/complex_target.json create mode 100644 tests/interpreter/cases/target/definitions/msgraph.json create mode 100644 tests/interpreter/cases/target/definitions/no_default_schema_target.json create mode 100644 tests/interpreter/cases/target/definitions/sample_target.json create mode 100644 tests/interpreter/cases/target/msgraph.yaml create mode 100644 tests/interpreter/cases/target/resource_type_inference.yaml diff --git a/.github/workflows/test-csharp.yml b/.github/workflows/test-csharp.yml index 049d21b8..ea385ace 100644 --- a/.github/workflows/test-csharp.yml +++ b/.github/workflows/test-csharp.yml @@ -140,12 +140,38 @@ jobs: uses: actions/download-artifact@v4 with: name: regorus-nuget - path: ./bindings/csharp/Regorus.Tests/regorus-nuget/ + path: ./bindings/csharp/regorus-nuget/ - name: Restore Regorus.Tests - run: dotnet restore /p:RestoreAdditionalProjectSources=./regorus-nuget + run: dotnet restore /p:RestoreAdditionalProjectSources=../regorus-nuget working-directory: ./bindings/csharp/Regorus.Tests - name: Run Regorus.Tests run: dotnet test --no-restore working-directory: ./bindings/csharp/Regorus.Tests + + - name: Restore TestApp + run: dotnet restore /p:RestoreAdditionalProjectSources=../regorus-nuget + working-directory: ./bindings/csharp/TestApp + + - name: Build TestApp + run: dotnet build --no-restore + working-directory: ./bindings/csharp/TestApp + + - name: Run TestApp + run: dotnet run --no-build --framework net8.0 + working-directory: ./bindings/csharp/TestApp + + - name: Restore TargetExampleApp + run: dotnet restore /p:RestoreAdditionalProjectSources=../regorus-nuget + working-directory: ./bindings/csharp/TargetExampleApp + + - name: Build TargetExampleApp + run: dotnet build --no-restore + working-directory: ./bindings/csharp/TargetExampleApp + + - name: Run TargetExampleApp + run: dotnet run --no-build --framework net8.0 + working-directory: ./bindings/csharp/TargetExampleApp + + diff --git a/Cargo.lock b/Cargo.lock index 51413a76..88956744 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -98,9 +98,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" [[package]] name = "autocfg" @@ -264,9 +264,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14cb31bb0a7d536caef2639baa7fad459e15c3144efefa6dbd1c84562c4739f6" dependencies = [ "heck", - "proc-macro2 1.0.96", + "proc-macro2 1.0.97", "quote 1.0.40", - "syn 2.0.104", + "syn 2.0.105", ] [[package]] @@ -377,9 +377,9 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ - "proc-macro2 1.0.96", + "proc-macro2 1.0.97", "quote 1.0.40", - "syn 2.0.104", + "syn 2.0.105", ] [[package]] @@ -982,9 +982,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.96" +version = "1.0.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "beef09f85ae72cea1ef96ba6870c51e6382ebfa4f0e85b643459331f3daa5be0" +checksum = "d61789d7719defeb74ea5fe81f2fdfdbd28a803847077cecce2ff14e1472f6f1" dependencies = [ "unicode-ident", ] @@ -1004,7 +1004,7 @@ version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ - "proc-macro2 1.0.96", + "proc-macro2 1.0.97", ] [[package]] @@ -1044,9 +1044,9 @@ dependencies = [ [[package]] name = "rayon" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" dependencies = [ "either", "rayon-core", @@ -1054,9 +1054,9 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", @@ -1086,9 +1086,9 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" dependencies = [ - "proc-macro2 1.0.96", + "proc-macro2 1.0.97", "quote 1.0.40", - "syn 2.0.104", + "syn 2.0.105", ] [[package]] @@ -1159,6 +1159,7 @@ dependencies = [ "serde_json", "serde_yaml", "test-generator", + "thiserror", "url", "uuid", "walkdir", @@ -1200,9 +1201,9 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2ee4885492bb655bfa05d039cd9163eb8fe9f79ddebf00ca23a1637510c2fd2" dependencies = [ - "proc-macro2 1.0.96", + "proc-macro2 1.0.97", "quote 1.0.40", - "syn 2.0.104", + "syn 2.0.105", ] [[package]] @@ -1232,9 +1233,9 @@ version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ - "proc-macro2 1.0.96", + "proc-macro2 1.0.97", "quote 1.0.40", - "syn 2.0.104", + "syn 2.0.105", ] [[package]] @@ -1311,11 +1312,11 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.104" +version = "2.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "7bc3fcb250e53458e712715cf74285c1f889686520d79294a9ef3bd7aa1fc619" dependencies = [ - "proc-macro2 1.0.96", + "proc-macro2 1.0.97", "quote 1.0.40", "unicode-ident", ] @@ -1326,9 +1327,9 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ - "proc-macro2 1.0.96", + "proc-macro2 1.0.97", "quote 1.0.40", - "syn 2.0.104", + "syn 2.0.105", ] [[package]] @@ -1343,6 +1344,26 @@ dependencies = [ "syn 0.15.44", ] +[[package]] +name = "thiserror" +version = "2.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b0949c3a6c842cbde3f1686d6eea5a010516deb7085f79db747562d4102f41e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc5b44b4ab9c2fdd0e0512e6bece8388e214c0749f5862b114cc5b7a25daf227" +dependencies = [ + "proc-macro2 1.0.97", + "quote 1.0.40", + "syn 2.0.105", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -1478,9 +1499,9 @@ checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" dependencies = [ "bumpalo", "log", - "proc-macro2 1.0.96", + "proc-macro2 1.0.97", "quote 1.0.40", - "syn 2.0.104", + "syn 2.0.105", "wasm-bindgen-shared", ] @@ -1500,9 +1521,9 @@ version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ - "proc-macro2 1.0.96", + "proc-macro2 1.0.97", "quote 1.0.40", - "syn 2.0.104", + "syn 2.0.105", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -1554,9 +1575,9 @@ version = "0.60.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ - "proc-macro2 1.0.96", + "proc-macro2 1.0.97", "quote 1.0.40", - "syn 2.0.104", + "syn 2.0.105", ] [[package]] @@ -1565,9 +1586,9 @@ version = "0.59.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ - "proc-macro2 1.0.96", + "proc-macro2 1.0.97", "quote 1.0.40", - "syn 2.0.104", + "syn 2.0.105", ] [[package]] @@ -1774,9 +1795,9 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ - "proc-macro2 1.0.96", + "proc-macro2 1.0.97", "quote 1.0.40", - "syn 2.0.104", + "syn 2.0.105", "synstructure", ] @@ -1795,9 +1816,9 @@ version = "0.8.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" dependencies = [ - "proc-macro2 1.0.96", + "proc-macro2 1.0.97", "quote 1.0.40", - "syn 2.0.104", + "syn 2.0.105", ] [[package]] @@ -1815,9 +1836,9 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ - "proc-macro2 1.0.96", + "proc-macro2 1.0.97", "quote 1.0.40", - "syn 2.0.104", + "syn 2.0.105", "synstructure", ] @@ -1849,7 +1870,7 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ - "proc-macro2 1.0.96", + "proc-macro2 1.0.97", "quote 1.0.40", - "syn 2.0.104", + "syn 2.0.105", ] diff --git a/Cargo.toml b/Cargo.toml index 89ed5132..52b01cc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -93,6 +93,7 @@ anyhow = { version = "1.0.45", default-features = false } serde = {version = "1.0.150", default-features = false, features = ["derive", "rc"] } serde_json = { version = "1.0.89", default-features = false, features = ["alloc"] } lazy_static = { version = "1.4.0", default-features = false } +thiserror = { version = "2.0", default-features = false } data-encoding = { version = "2.8.0", optional = true, default-features=false, features = ["alloc"] } scientific = { version = "0.5.3" } diff --git a/bindings/c-nostd/main.c b/bindings/c-nostd/main.c index 99617c86..36127c7e 100644 --- a/bindings/c-nostd/main.c +++ b/bindings/c-nostd/main.c @@ -43,27 +43,27 @@ int main() { // Turn on rego v0 since policy uses v0. r = regorus_engine_set_rego_v0(engine, true); - if (r.status != RegorusStatusOk) + if (r.status != Ok) goto error; // Load policies. r = regorus_engine_add_policy(engine, "framework.rego", (buffer = file_to_string("../../../tests/aci/framework.rego"))); free(buffer); - if (r.status != RegorusStatusOk) + if (r.status != Ok) goto error; printf("Loaded package %s\n", r.output); regorus_result_drop(r); r = regorus_engine_add_policy(engine, "api.rego", (buffer = file_to_string("../../../tests/aci/api.rego"))); free(buffer); - if (r.status != RegorusStatusOk) + if (r.status != Ok) goto error; printf("Loaded package %s\n", r.output); regorus_result_drop(r); r = regorus_engine_add_policy(engine, "policy.rego", (buffer = file_to_string("../../../tests/aci/policy.rego"))); free(buffer); - if (r.status != RegorusStatusOk) + if (r.status != Ok) goto error; printf("Loaded package %s\n", r.output); regorus_result_drop(r); @@ -71,20 +71,20 @@ int main() { // Add data r = regorus_engine_add_data_json(engine, (buffer = file_to_string("../../../tests/aci/data.json"))); free(buffer); - if (r.status != RegorusStatusOk) + if (r.status != Ok) goto error; regorus_result_drop(r); // Set input r = regorus_engine_set_input_json(engine, (buffer = file_to_string("../../../tests/aci/input.json"))); free(buffer); - if (r.status != RegorusStatusOk) + if (r.status != Ok) goto error; regorus_result_drop(r); // Eval rule. r = regorus_engine_eval_rule(engine, "data.framework.mount_overlay"); - if (r.status != RegorusStatusOk) + if (r.status != Ok) goto error; // Print output diff --git a/bindings/c/main.c b/bindings/c/main.c index 30480943..887adc87 100644 --- a/bindings/c/main.c +++ b/bindings/c/main.c @@ -8,43 +8,43 @@ int main() { // Turn on rego v0 since policy uses v0. r = regorus_engine_set_rego_v0(engine, true); - if (r.status != RegorusStatusOk) + if (r.status != Ok) goto error; // Load policies. r = regorus_engine_add_policy_from_file(engine, "../../../tests/aci/framework.rego"); - if (r.status != RegorusStatusOk) + if (r.status != Ok) goto error; printf("Loaded package %s\n", r.output); regorus_result_drop(r); r = regorus_engine_add_policy_from_file(engine, "../../../tests/aci/api.rego"); - if (r.status != RegorusStatusOk) + if (r.status != Ok) goto error; printf("Loaded package %s\n", r.output); regorus_result_drop(r); r = regorus_engine_add_policy_from_file(engine, "../../../tests/aci/policy.rego"); - if (r.status != RegorusStatusOk) + if (r.status != Ok) goto error; printf("Loaded package %s\n", r.output); regorus_result_drop(r); // Add data r = regorus_engine_add_data_from_json_file(engine, "../../../tests/aci/data.json"); - if (r.status != RegorusStatusOk) + if (r.status != Ok) goto error; regorus_result_drop(r); // Set input r = regorus_engine_set_input_from_json_file(engine, "../../../tests/aci/input.json"); - if (r.status != RegorusStatusOk) + if (r.status != Ok) goto error; regorus_result_drop(r); // Eval rule. r = regorus_engine_eval_query(engine, "data.framework.mount_overlay"); - if (r.status != RegorusStatusOk) + if (r.status != Ok) goto error; // Print output @@ -66,14 +66,14 @@ int main() { ); // Evaluate rule. - if (r.status != RegorusStatusOk) + if (r.status != Ok) goto error; r = regorus_engine_set_enable_coverage(engine, true); regorus_result_drop(r); r = regorus_engine_eval_query(engine, "data.test.message"); - if (r.status != RegorusStatusOk) + if (r.status != Ok) goto error; // Print output @@ -82,7 +82,7 @@ int main() { // Print pretty coverage report. r = regorus_engine_get_coverage_report_pretty(engine); - if (r.status != RegorusStatusOk) + if (r.status != Ok) goto error; printf("%s\n", r.output); diff --git a/bindings/cpp/regorus.hpp b/bindings/cpp/regorus.hpp index 3cfd1283..6a81dcc4 100644 --- a/bindings/cpp/regorus.hpp +++ b/bindings/cpp/regorus.hpp @@ -11,8 +11,8 @@ namespace regorus { class Result { public: - operator bool() const { return result.status == RegorusStatus::RegorusStatusOk; } - bool operator !() const { return result.status != RegorusStatus::RegorusStatusOk; } + operator bool() const { return result.status == RegorusStatus::Ok; } + bool operator !() const { return result.status != RegorusStatus::Ok; } const char* output() const { if (*this && result.output) { diff --git a/bindings/csharp/API.md b/bindings/csharp/API.md new file mode 100644 index 00000000..a8b0c83f --- /dev/null +++ b/bindings/csharp/API.md @@ -0,0 +1,421 @@ +# Regorus C# API Documentation + +This document describes the C# API for Regorus, focusing on the compiled policy approach for high-performance policy evaluation. + +## Overview + +The Regorus C# bindings provide a modern, thread-safe API for compiling and evaluating Open Policy Agent (OPA) Rego policies. The API is designed around pre-compiled policies that can be evaluated efficiently multiple times with different inputs. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ CompiledPolicy Workflow │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ Policy Modules │ │ Target/Schema │ │ Static Data │ +│ (.rego files) │ │ Registries │ │ (JSON) │ +└─────────┬───────┘ └────────┬─────────┘ └─────────┬───────┘ + │ │ │ + └─────────────────────┼────────────────────────┘ + │ + ▼ + ┌─────────────────────────┐ + │ Compile │ + │ ┌─────────────────────┐│ + │ │ Parse & Analyze ││ + │ │ Infer Resource Types││ + │ │ Build AST & Rules ││ + │ │ Target Integration ││ + │ └─────────────────────┘│ + └─────────────┬───────────┘ + │ + ▼ + ┌─────────────────────────┐ + │ CompiledPolicy │ + │ ┌─────────────────────┐ │ + │ │ AST & Rules │ │ + │ │ Target Info │ │ + │ │ Resource Types │ │ + │ │ Function Table │ │ + │ │ Compiled Modules │ │ + │ └─────────────────────┘ │ + └─────────────┬───────────┘ + │ + ▼ + ┌─────────────────────┐ + │ Service Cache │ + │ (Policy Framework, │ + │ MS Graph, etc.) │ + │ ┌─────────────────┐ │ + │ │ CompiledPolicy │ │ ◄─── Same LOCK-FREE policy + │ │ (cached) │ │ instance shared across + │ └─────────────────┘ │ all threads + └─────────┬───────────┘ + │ + ┌───────┼───────┬───────┐ + │ │ │ │ + ▼ ▼ ▼ ▼ + ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ + │ Thread 1 │ │ Thread 2 │ │ Thread N │ + │ │ │ │ │ │ + │ input1 ────▶│ │ input2 ────▶│ │ inputN ────▶│ + │ ◄─── result │ │ ◄─── result │ │ ◄─── result │ + └─────────────┘ └─────────────┘ └─────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ Key Benefits │ +├─────────────────────────────────────────────────────────────────┤ +│ ✓ Compile Once, Evaluate Many ✓ Lock-Free Concurrent Eval │ +│ ✓ No Re-parsing Overhead ✓ Reference Counting Safety │ +│ ✓ Reduced GC Pressure ✓ Proper Resource Management │ +│ ✓ Cache-Friendly Design ✓ Target System Integration │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Key Features + +- **Pre-compiled Policies**: Compile once, evaluate many times for optimal performance +- **Target System Support**: Built-in support for Azure Policy targets with resource type inference +- **Thread Safety**: All operations are thread-safe without external synchronization +- **Registry Management**: Centralized management of targets and schemas +- **Policy Introspection**: Rich metadata about compiled policies + +## Core Classes + +### CompiledPolicy + +The `CompiledPolicy` class represents a pre-compiled Rego policy that can be evaluated efficiently. + +```csharp +public sealed class CompiledPolicy : IDisposable +{ + // Evaluate the policy with input data + public string? EvalWithInput(string inputJson); + + // Get comprehensive policy metadata + public PolicyInfo GetPolicyInfo(); + + // Dispose of unmanaged resources + public void Dispose(); +} +``` + +**Thread Safety**: All methods are thread-safe. Multiple threads can call `EvalWithInput()` concurrently, and `Dispose()` will safely wait for active evaluations to complete. + +### Compiler + +The `Compiler` class provides static methods for compiling policies. + +```csharp +public static class Compiler +{ + // Compile a policy with a specific entrypoint rule + public static CompiledPolicy CompilePolicyWithEntrypoint( + string dataJson, + IEnumerable modules, + string entryPointRule); + + // Compile a target-aware policy (requires azure_policy feature) + public static CompiledPolicy CompilePolicyForTarget( + string dataJson, + IEnumerable modules); +} +``` + +### PolicyModule + +Represents a single policy module to be compiled. Each PolicyModule corresponds to a Rego file (.rego), and each Rego file defines a Rego package using the `package` declaration at the top of the file. + +```csharp +public struct PolicyModule +{ + public string Id { get; set; } + public string Content { get; set; } + + public PolicyModule(string id, string content); +} +``` + +**Properties:** +- `Id`: A unique identifier for the module, typically the filename (e.g., "policy.rego", "rules/storage.rego") +- `Content`: The complete Rego policy content, including the `package` declaration and all rules + +**Example:** +```csharp +var module = new PolicyModule("storage-policy.rego", @" + package azure.storage + import rego.v1 + + default allow := false + allow if input.type == ""Microsoft.Storage/storageAccounts"" +"); +``` + +### PolicyInfo + +Provides comprehensive metadata about a compiled policy. + +```csharp +public class PolicyInfo +{ + // List of module identifiers + public List ModuleIds { get; set; } + + // Target name (for target-aware policies) + public string? TargetName { get; set; } + + // Resource types this policy can evaluate + public List ApplicableResourceTypes { get; set; } + + // Primary rule/entrypoint + public string EntrypointRule { get; set; } + + // Effect rule (for target-aware policies) + public string? EffectRule { get; set; } + + // Policy parameters + public List Parameters { get; set; } +} +``` + +## Registry Classes + +### TargetRegistry + +Manages target definitions for Azure Policy-style evaluations. + +```csharp +public static class TargetRegistry +{ + // Register a target from JSON + public static void RegisterFromJson(string targetJson); + + // Check if a target exists + public static bool Contains(string name); + + // List all registered targets + public static string ListNames(); + + // Remove a target + public static bool Remove(string name); + + // Clear all targets + public static void Clear(); + + // Get count of registered targets + public static int Count { get; } + + // Check if registry is empty + public static bool IsEmpty { get; } +} +``` + +### SchemaRegistry + +Manages schema definitions for validation. + +```csharp +public static class SchemaRegistry +{ + // Register resource schemas + public static void RegisterResourceSchema(string name, string schemaJson); + public static bool ContainsResourceSchema(string name); + public static string ListResourceSchemas(); + + // Register effect schemas + public static void RegisterEffectSchema(string name, string schemaJson); + public static bool ContainsEffectSchema(string name); + public static string ListEffectSchemas(); + + // Clear methods + public static void ClearResourceSchemas(); + public static void ClearEffectSchemas(); +} +``` + +## Usage Examples + +### Basic Policy Compilation and Evaluation + +```csharp +// Define policy modules +var modules = new List +{ + new PolicyModule("policy.rego", @" + package example + import rego.v1 + + default allow := false + allow if input.user == ""admin"" + ") +}; + +// Compile the policy +using var policy = Compiler.CompilePolicyWithEntrypoint("{}", modules, "data.example.allow"); + +// Evaluate with different inputs +var result1 = policy.EvalWithInput(@"{""user"": ""admin""}"); // true +var result2 = policy.EvalWithInput(@"{""user"": ""guest""}"); // false +``` + +### Target-Aware Policy (Azure Policy Style) + +```csharp +// Register target definition +TargetRegistry.RegisterFromJson(@"{ + ""name"": ""azure.storage"", + ""resource_schema_selector"": ""type"", + ""resource_types"": { + ""Microsoft.Storage/storageAccounts"": { + ""schema"": { /* JSON Schema */ } + } + } +}"); + +// Define policy with target +var modules = new List +{ + new PolicyModule("policy.rego", @" + package policy + import rego.v1 + + __target__ := ""azure.storage"" + + default effect := ""deny"" + effect := ""allow"" if { + input.type == ""Microsoft.Storage/storageAccounts"" + input.properties.supportsHttpsTrafficOnly == true + } + ") +}; + +// Compile for target +using var policy = Compiler.CompilePolicyForTarget("{}", modules); + +// Evaluate Azure resource +var resource = @"{ + ""type"": ""Microsoft.Storage/storageAccounts"", + ""properties"": { + ""supportsHttpsTrafficOnly"": true + } +}"; + +var result = policy.EvalWithInput(resource); // "allow" +``` + +### Policy Introspection + +```csharp +// Get policy metadata +var info = policy.GetPolicyInfo(); + +Console.WriteLine($"Target: {info.TargetName}"); +Console.WriteLine($"Effect Rule: {info.EffectRule}"); +Console.WriteLine($"Modules: {string.Join(", ", info.ModuleIds)}"); +Console.WriteLine($"Resource Types: {string.Join(", ", info.ApplicableResourceTypes)}"); + +// Access parameters +if (info.Parameters != null && info.Parameters.Count > 0) +{ + foreach (var parameterSet in info.Parameters) + { + Console.WriteLine($"Module: {parameterSet.SourceFile}"); + foreach (var param in parameterSet.Parameters) + { + Console.WriteLine($"Parameter: {param.Name} ({param.Type})"); + if (param.Default != null) + Console.WriteLine($" Default: {param.Default}"); + } + } +} +``` + +### Concurrent Evaluation + +```csharp +// CompiledPolicy is thread-safe +var tasks = Enumerable.Range(0, 100).Select(i => + Task.Run(() => policy.EvalWithInput($@"{{""id"": {i}}}")) +).ToArray(); + +var results = await Task.WhenAll(tasks); +``` + +## Performance Considerations + +### Compilation Overhead + +- Policy compilation has significant overhead due to parsing and analysis +- **Best Practice**: Compile once, reuse many times +- Consider caching compiled policies for repeated use + +### Memory Management + +- `CompiledPolicy` manages unmanaged resources +- **Always** dispose of compiled policies using `using` statements or explicit `Dispose()` +- Disposal is thread-safe and waits for active evaluations + +### Thread Safety + +- All classes are thread-safe for concurrent reads/evaluations +- Registry modifications should be done during initialization +- No external synchronization required + +## Error Handling + +All methods throw `Exception` on errors with descriptive messages: + +```csharp +try +{ + var policy = Compiler.CompilePolicyWithEntrypoint(data, modules, rule); + var result = policy.EvalWithInput(input); +} +catch (Exception ex) +{ + Console.WriteLine($"Error: {ex.Message}"); +} +``` + +## Feature Flags + +Some functionality requires specific Rust feature flags: + +- **azure_policy**: Required for target-aware compilation and policy parameters +- Without this feature, target-related methods will not be available + +## Version Compatibility + +- Requires .NET Standard 2.0 or later +- Compatible with .NET Framework 4.6.1+, .NET Core 2.0+, .NET 5+ +- Uses System.Text.Json for JSON serialization (added as dependency) + +## Best Practices + +1. **Compile Once, Evaluate Many**: Pre-compile policies for repeated evaluation +2. **Use Disposable Pattern**: Always dispose of CompiledPolicy instances +3. **Thread-Safe Design**: Take advantage of built-in thread safety +4. **Registry Setup**: Configure targets and schemas during application startup +5. **Error Handling**: Wrap operations in try-catch blocks for robust error handling +6. **Performance Monitoring**: Monitor evaluation times for performance optimization + +## Migration from Engine-Based API + +If migrating from an engine-based approach: + +```csharp +// Old approach (if it existed) +// var engine = new Engine(); +// engine.AddPolicy("policy.rego", policyContent); +// engine.SetInputJson(inputJson); +// var result = engine.EvalRule("data.policy.allow"); + +// New compiled approach +var modules = new[] { new PolicyModule("policy.rego", policyContent) }; +using var policy = Compiler.CompilePolicyWithEntrypoint("{}", modules, "data.policy.allow"); +var result = policy.EvalWithInput(inputJson); +``` + +The compiled approach provides better performance for repeated evaluations and clearer resource management. diff --git a/bindings/csharp/Regorus/CompiledPolicy.cs b/bindings/csharp/Regorus/CompiledPolicy.cs new file mode 100644 index 00000000..46aaebef --- /dev/null +++ b/bindings/csharp/Regorus/CompiledPolicy.cs @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Text; +using System.Text.Json; + +#nullable enable +namespace Regorus +{ + /// + /// Represents a compiled Regorus policy that can be evaluated efficiently. + /// This class wraps a pre-compiled policy that can be evaluated multiple times + /// with different inputs without recompilation overhead. + /// + /// This class manages unmanaged resources and should not be copied or cloned. + /// Each instance represents a unique native policy object. + /// + /// Thread Safety: This class is thread-safe for all operations. Multiple threads + /// can safely call EvalWithInput() concurrently, and Dispose() will safely wait + /// for all active evaluations to complete before freeing resources. No external + /// synchronization is required. + /// + public unsafe sealed class CompiledPolicy : IDisposable + { + private Internal.RegorusCompiledPolicy* _policy; + private int _isDisposed; + private int _activeEvaluations; + + internal CompiledPolicy(Internal.RegorusCompiledPolicy* policy) + { + _policy = policy; + } + + /// + /// Evaluates the compiled policy with the given input. + /// For target policies, evaluates the target's effect rule. + /// For regular policies, evaluates the originally compiled rule. + /// + /// JSON encoded input data (resource) to validate against the policy + /// The evaluation result as JSON string + /// Thrown when policy evaluation fails + /// Thrown when the policy has been disposed + public string? EvalWithInput(string inputJson) + { + // Increment active evaluations count + System.Threading.Interlocked.Increment(ref _activeEvaluations); + try + { + ThrowIfDisposed(); + + var inputBytes = Encoding.UTF8.GetBytes(inputJson + char.MinValue); + fixed (byte* inputPtr = inputBytes) + { + return CheckAndDropResult(Internal.API.regorus_compiled_policy_eval_with_input(_policy, inputPtr)); + } + } + finally + { + // Decrement active evaluations count + System.Threading.Interlocked.Decrement(ref _activeEvaluations); + } + } + + /// + /// Gets information about the compiled policy including metadata about modules, + /// target configuration, and resource types. + /// + /// Policy information containing module IDs, target name, applicable resource types, entry point rule, and parameters + /// Thrown when getting policy info fails + /// Thrown when the policy has been disposed + public PolicyInfo GetPolicyInfo() + { + ThrowIfDisposed(); + var jsonResult = CheckAndDropResult(Internal.API.regorus_compiled_policy_get_policy_info(_policy)); + + if (string.IsNullOrEmpty(jsonResult)) + { + throw new Exception("Failed to get policy info: empty response"); + } + + try + { + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + return JsonSerializer.Deserialize(jsonResult!, options) + ?? throw new Exception("Failed to deserialize policy info"); + } + catch (JsonException ex) + { + throw new Exception($"Failed to parse policy info JSON: {ex.Message}", ex); + } + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (System.Threading.Interlocked.CompareExchange(ref _isDisposed, 1, 0) == 0) + { + if (_policy != null) + { + // Wait for all active evaluations to complete + while (System.Threading.Volatile.Read(ref _activeEvaluations) > 0) + { + System.Threading.Thread.Yield(); + } + + Internal.API.regorus_compiled_policy_drop(_policy); + _policy = null; + } + } + } + + ~CompiledPolicy() => Dispose(disposing: false); + + private void ThrowIfDisposed() + { + if (_isDisposed != 0) + throw new ObjectDisposedException(nameof(CompiledPolicy)); + } + + private string? StringFromUTF8(IntPtr ptr) + { +#if NETSTANDARD2_1 + return System.Runtime.InteropServices.Marshal.PtrToStringUTF8(ptr); +#else + int len = 0; + while (System.Runtime.InteropServices.Marshal.ReadByte(ptr, len) != 0) { ++len; } + byte[] buffer = new byte[len]; + System.Runtime.InteropServices.Marshal.Copy(ptr, buffer, 0, buffer.Length); + return Encoding.UTF8.GetString(buffer); +#endif + } + + private string? CheckAndDropResult(Internal.RegorusResult result) + { + try + { + if (result.status != Internal.RegorusStatus.Ok) + { + var message = StringFromUTF8((IntPtr)result.error_message); + throw new Exception(message ?? "Unknown error occurred"); + } + + return result.data_type switch + { + Internal.RegorusDataType.String => StringFromUTF8((IntPtr)result.output), + Internal.RegorusDataType.Boolean => result.bool_value.ToString().ToLowerInvariant(), + Internal.RegorusDataType.Integer => result.int_value.ToString(), + Internal.RegorusDataType.None => null, + _ => StringFromUTF8((IntPtr)result.output) + }; + } + finally + { + Internal.API.regorus_result_drop(result); + } + } + } +} diff --git a/bindings/csharp/Regorus/Compiler.cs b/bindings/csharp/Regorus/Compiler.cs new file mode 100644 index 00000000..aa27a5c8 --- /dev/null +++ b/bindings/csharp/Regorus/Compiler.cs @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; + +#nullable enable +namespace Regorus +{ + /// + /// Represents a policy module with an ID and content. + /// + public struct PolicyModule + { + /// + /// Gets or sets the unique identifier for this policy module. + /// + public string Id { get; set; } + + /// + /// Gets or sets the Rego policy content. + /// + public string Content { get; set; } + + /// + /// Initializes a new instance of the PolicyModule struct. + /// + /// The unique identifier for this policy module + /// The Rego policy content + public PolicyModule(string id, string content) + { + Id = id; + Content = content; + } + } + + /// + /// Provides static methods for compiling policies into efficient compiled representations. + /// These are convenience methods that create an engine internally and perform compilation. + /// + public static unsafe class Compiler + { + /// + /// Compiles a policy from data and modules with a specific entry point rule. + /// This is a convenience function that sets up an Engine internally and calls the appropriate compilation method. + /// + /// JSON string containing static data for policy evaluation + /// List of policy modules to compile + /// The specific rule path to evaluate (e.g., "data.policy.allow") + /// A compiled policy that can be evaluated efficiently + /// Thrown when compilation fails + public static CompiledPolicy CompilePolicyWithEntrypoint(string dataJson, IEnumerable modules, string entryPointRule) + { + var dataBytes = Encoding.UTF8.GetBytes(dataJson + char.MinValue); + var entryPointBytes = Encoding.UTF8.GetBytes(entryPointRule + char.MinValue); + var modulesArray = modules.ToArray(); + + // Convert C# modules to native structs + var nativeModules = new Internal.RegorusPolicyModule[modulesArray.Length]; + var pinnedHandles = new List(); + + try + { + for (int i = 0; i < modulesArray.Length; i++) + { + var idBytes = Encoding.UTF8.GetBytes(modulesArray[i].Id + char.MinValue); + var contentBytes = Encoding.UTF8.GetBytes(modulesArray[i].Content + char.MinValue); + + var idHandle = GCHandle.Alloc(idBytes, GCHandleType.Pinned); + var contentHandle = GCHandle.Alloc(contentBytes, GCHandleType.Pinned); + pinnedHandles.Add(idHandle); + pinnedHandles.Add(contentHandle); + + nativeModules[i] = new Internal.RegorusPolicyModule + { + id = (byte*)idHandle.AddrOfPinnedObject(), + content = (byte*)contentHandle.AddrOfPinnedObject() + }; + } + + fixed (byte* dataPtr = dataBytes) + fixed (byte* entryPointPtr = entryPointBytes) + fixed (Internal.RegorusPolicyModule* modulesPtr = nativeModules) + { + var result = Internal.API.regorus_compile_policy_with_entrypoint( + dataPtr, modulesPtr, (UIntPtr)modulesArray.Length, entryPointPtr); + + var policy = GetCompiledPolicyResult(result); + return policy; + } + } + finally + { + foreach (var handle in pinnedHandles) + { + handle.Free(); + } + } + } + + /// + /// Compiles a target-aware policy from data and modules. + /// This is a convenience function that sets up an Engine internally and calls target-aware compilation. + /// At least one module must contain a `__target__` declaration. + /// + /// JSON string containing static data for policy evaluation + /// List of policy modules to compile + /// A compiled policy that can be evaluated efficiently + /// Thrown when compilation fails + public static CompiledPolicy CompilePolicyForTarget(string dataJson, IEnumerable modules) + { + var dataBytes = Encoding.UTF8.GetBytes(dataJson + char.MinValue); + var modulesArray = modules.ToArray(); + + // Convert C# modules to native structs + var nativeModules = new Internal.RegorusPolicyModule[modulesArray.Length]; + var pinnedHandles = new List(); + + try + { + for (int i = 0; i < modulesArray.Length; i++) + { + var idBytes = Encoding.UTF8.GetBytes(modulesArray[i].Id + char.MinValue); + var contentBytes = Encoding.UTF8.GetBytes(modulesArray[i].Content + char.MinValue); + + var idHandle = GCHandle.Alloc(idBytes, GCHandleType.Pinned); + var contentHandle = GCHandle.Alloc(contentBytes, GCHandleType.Pinned); + pinnedHandles.Add(idHandle); + pinnedHandles.Add(contentHandle); + + nativeModules[i] = new Internal.RegorusPolicyModule + { + id = (byte*)idHandle.AddrOfPinnedObject(), + content = (byte*)contentHandle.AddrOfPinnedObject() + }; + } + + fixed (byte* dataPtr = dataBytes) + fixed (Internal.RegorusPolicyModule* modulesPtr = nativeModules) + { + var result = Internal.API.regorus_compile_policy_for_target( + dataPtr, modulesPtr, (UIntPtr)modulesArray.Length); + + var policy = GetCompiledPolicyResult(result); + return policy; + } + } + finally + { + foreach (var handle in pinnedHandles) + { + handle.Free(); + } + } + } + + private static string? StringFromUTF8(IntPtr ptr) + { +#if NETSTANDARD2_1 + return System.Runtime.InteropServices.Marshal.PtrToStringUTF8(ptr); +#else + int len = 0; + while (System.Runtime.InteropServices.Marshal.ReadByte(ptr, len) != 0) { ++len; } + byte[] buffer = new byte[len]; + System.Runtime.InteropServices.Marshal.Copy(ptr, buffer, 0, buffer.Length); + return Encoding.UTF8.GetString(buffer); +#endif + } + + private static CompiledPolicy GetCompiledPolicyResult(Internal.RegorusResult result) + { + try + { + if (result.status != Internal.RegorusStatus.Ok) + { + var message = StringFromUTF8((IntPtr)result.error_message); + throw new Exception(message ?? "Unknown compilation error occurred"); + } + + if (result.data_type != Internal.RegorusDataType.Pointer || result.pointer_value == null) + { + throw new Exception("Expected compiled policy pointer but got different data type"); + } + + return new CompiledPolicy((Internal.RegorusCompiledPolicy*)result.pointer_value); + } + finally + { + Internal.API.regorus_result_drop(result); + } + } + } +} diff --git a/bindings/csharp/Regorus/Regorus.cs b/bindings/csharp/Regorus/Engine.cs similarity index 99% rename from bindings/csharp/Regorus/Regorus.cs rename to bindings/csharp/Regorus/Engine.cs index c0b53c83..4773d4c2 100644 --- a/bindings/csharp/Regorus/Regorus.cs +++ b/bindings/csharp/Regorus/Engine.cs @@ -243,7 +243,7 @@ public void SetGatherPrints(bool enable) string? CheckAndDropResult(Regorus.Internal.RegorusResult result) { - if (result.status != Regorus.Internal.RegorusStatus.RegorusStatusOk) + if (result.status != Regorus.Internal.RegorusStatus.Ok) { var message = StringFromUTF8((IntPtr)result.error_message); var ex = new Exception(message); diff --git a/bindings/csharp/Regorus/NativeMethods.cs b/bindings/csharp/Regorus/NativeMethods.cs new file mode 100644 index 00000000..a13bb77e --- /dev/null +++ b/bindings/csharp/Regorus/NativeMethods.cs @@ -0,0 +1,546 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Runtime.InteropServices; + +#pragma warning disable CS8500 +#pragma warning disable CS8981 + +namespace Regorus.Internal +{ + /// + /// Native FFI method declarations for Regorus. + /// This file contains all P/Invoke declarations for the Regorus native library. + /// + internal static unsafe partial class API + { + private const string LibraryName = "regorus_ffi"; + + #region Common Methods + + /// + /// Drop a RegorusResult. + /// output and error_message strings are not valid after drop. + /// + [DllImport(LibraryName, EntryPoint = "regorus_result_drop", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern void regorus_result_drop(RegorusResult result); + + #endregion + + #region Engine Methods + + /// + /// Construct a new Engine. + /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_new", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusEngine* regorus_engine_new(); + + /// + /// Clone a RegorusEngine. + /// To avoid having to parse same policy again, the engine can be cloned + /// after policies and data have been added. + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_clone", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusEngine* regorus_engine_clone(RegorusEngine* engine); + + /// + /// Drop a RegorusEngine. + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_drop", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern void regorus_engine_drop(RegorusEngine* engine); + + /// + /// Add a policy. + /// The policy is parsed into AST. + /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.add_policy + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_add_policy", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_engine_add_policy(RegorusEngine* engine, byte* path, byte* rego); + + /// + /// Add a policy from file. + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_add_policy_from_file", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_engine_add_policy_from_file(RegorusEngine* engine, byte* path); + + /// + /// Add policy data. + /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.add_data + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_add_data_json", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_engine_add_data_json(RegorusEngine* engine, byte* data); + + /// + /// Get list of loaded Rego packages as JSON. + /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.get_packages + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_get_packages", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_engine_get_packages(RegorusEngine* engine); + + /// + /// Get list of policies as JSON. + /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.get_policies + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_get_policies", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_engine_get_policies(RegorusEngine* engine); + + /// + /// Add data from JSON file. + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_add_data_from_json_file", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_engine_add_data_from_json_file(RegorusEngine* engine, byte* path); + + /// + /// Clear policy data. + /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.clear_data + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_clear_data", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_engine_clear_data(RegorusEngine* engine); + + /// + /// Set input. + /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.set_input + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_set_input_json", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_engine_set_input_json(RegorusEngine* engine, byte* input); + + /// + /// Set input from JSON file. + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_set_input_from_json_file", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_engine_set_input_from_json_file(RegorusEngine* engine, byte* path); + + /// + /// Evaluate query. + /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.eval_query + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_eval_query", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_engine_eval_query(RegorusEngine* engine, byte* query); + + /// + /// Evaluate specified rule. + /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.eval_rule + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_eval_rule", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_engine_eval_rule(RegorusEngine* engine, byte* rule); + + /// + /// Enable/disable coverage. + /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.set_enable_coverage + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_set_enable_coverage", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_engine_set_enable_coverage(RegorusEngine* engine, [MarshalAs(UnmanagedType.U1)] bool enable); + + /// + /// Get coverage report. + /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.get_coverage_report + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_get_coverage_report", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_engine_get_coverage_report(RegorusEngine* engine); + + /// + /// Enable/disable strict builtin errors. + /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.set_strict_builtin_errors + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_set_strict_builtin_errors", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_engine_set_strict_builtin_errors(RegorusEngine* engine, [MarshalAs(UnmanagedType.U1)] bool strict); + + /// + /// Get pretty printed coverage report. + /// See https://docs.rs/regorus/latest/regorus/coverage/struct.Report.html#method.to_string_pretty + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_get_coverage_report_pretty", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_engine_get_coverage_report_pretty(RegorusEngine* engine); + + /// + /// Clear coverage data. + /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.clear_coverage_data + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_clear_coverage_data", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_engine_clear_coverage_data(RegorusEngine* engine); + + /// + /// Whether to gather output of print statements. + /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.set_gather_prints + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_set_gather_prints", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_engine_set_gather_prints(RegorusEngine* engine, [MarshalAs(UnmanagedType.U1)] bool enable); + + /// + /// Take all the gathered print statements. + /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.take_prints + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_take_prints", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_engine_take_prints(RegorusEngine* engine); + + /// + /// Get AST of policies. + /// See https://docs.rs/regorus/latest/regorus/coverage/struct.Engine.html#method.get_ast_as_json + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_get_ast_as_json", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_engine_get_ast_as_json(RegorusEngine* engine); + + /// + /// Gets the package names defined in each policy added to the engine. + /// See https://docs.rs/regorus/latest/regorus/coverage/struct.Engine.html#method.get_policy_package_names + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_get_policy_package_names", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_engine_get_policy_package_names(RegorusEngine* engine); + + /// + /// Gets the parameters defined in each policy added to the engine. + /// See https://docs.rs/regorus/latest/regorus/coverage/struct.Engine.html#method.get_policy_parameters + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_get_policy_parameters", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_engine_get_policy_parameters(RegorusEngine* engine); + + /// + /// Enable/disable rego v1. + /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.set_rego_v0 + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_set_rego_v0", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_engine_set_rego_v0(RegorusEngine* engine, [MarshalAs(UnmanagedType.U1)] bool enable); + + /// + /// Compile a target-aware policy from the current engine state. + /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.compile_for_target + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_compile_for_target", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_engine_compile_for_target(RegorusEngine* engine); + + /// + /// Compile a policy with a specific entry point rule. + /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.compile_with_entrypoint + /// + [DllImport(LibraryName, EntryPoint = "regorus_engine_compile_with_entrypoint", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_engine_compile_with_entrypoint(RegorusEngine* engine, byte* rule); + + #endregion + + #region Compilation Methods + + /// + /// Compiles a policy from data and modules with a specific entry point rule. + /// This is a convenience function that wraps regorus::compile_policy_with_entrypoint. + /// + [DllImport(LibraryName, EntryPoint = "regorus_compile_policy_with_entrypoint", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_compile_policy_with_entrypoint(byte* data_json, RegorusPolicyModule* modules, UIntPtr modules_len, byte* entry_point_rule); + + /// + /// Compiles a target-aware policy from data and modules. + /// This is a convenience function that wraps regorus::compile_policy_for_target. + /// + [DllImport(LibraryName, EntryPoint = "regorus_compile_policy_for_target", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_compile_policy_for_target(byte* data_json, RegorusPolicyModule* modules, UIntPtr modules_len); + + #endregion + + #region Compiled Policy Methods + + /// + /// Drop a RegorusCompiledPolicy. + /// + [DllImport(LibraryName, EntryPoint = "regorus_compiled_policy_drop", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern void regorus_compiled_policy_drop(RegorusCompiledPolicy* compiled_policy); + + /// + /// Evaluate the compiled policy with the given input. + /// For target policies, evaluates the target's effect rule. + /// For regular policies, evaluates the originally compiled rule. + /// + [DllImport(LibraryName, EntryPoint = "regorus_compiled_policy_eval_with_input", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_compiled_policy_eval_with_input(RegorusCompiledPolicy* compiled_policy, byte* input); + + /// + /// Get information about the compiled policy including metadata about modules, + /// target configuration, and resource types. + /// Returns a JSON-encoded PolicyInfo struct containing comprehensive + /// information about the compiled policy such as module IDs, target name, + /// applicable resource types, entry point rule, and parameters. + /// + [DllImport(LibraryName, EntryPoint = "regorus_compiled_policy_get_policy_info", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_compiled_policy_get_policy_info(RegorusCompiledPolicy* compiled_policy); + + #endregion + + #region Target Registry Methods + + /// + /// Register a target from JSON definition. + /// The target JSON should follow the target schema format. + /// Once registered, the target can be referenced in Rego policies using __target__ rules. + /// + [DllImport(LibraryName, EntryPoint = "regorus_register_target_from_json", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_register_target_from_json(byte* target_json); + + /// + /// Check if a target is registered. + /// + [DllImport(LibraryName, EntryPoint = "regorus_target_registry_contains", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_target_registry_contains(byte* name); + + /// + /// Get a list of all registered target names as JSON array. + /// + [DllImport(LibraryName, EntryPoint = "regorus_target_registry_list_names", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_target_registry_list_names(); + + /// + /// Remove a target from the registry by name. + /// + [DllImport(LibraryName, EntryPoint = "regorus_target_registry_remove", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_target_registry_remove(byte* name); + + /// + /// Clear all targets from the registry. + /// + [DllImport(LibraryName, EntryPoint = "regorus_target_registry_clear", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_target_registry_clear(); + + /// + /// Get the number of registered targets. + /// + [DllImport(LibraryName, EntryPoint = "regorus_target_registry_len", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_target_registry_len(); + + /// + /// Check if the target registry is empty. + /// + [DllImport(LibraryName, EntryPoint = "regorus_target_registry_is_empty", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_target_registry_is_empty(); + + #endregion + + #region Resource Schema Registry Methods + + /// + /// Register a resource schema from JSON with a given name. + /// + [DllImport(LibraryName, EntryPoint = "regorus_resource_schema_register", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_resource_schema_register(byte* name, byte* schema_json); + + /// + /// Check if a resource schema with the given name exists. + /// + [DllImport(LibraryName, EntryPoint = "regorus_resource_schema_contains", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_resource_schema_contains(byte* name); + + /// + /// Get the number of registered resource schemas. + /// + [DllImport(LibraryName, EntryPoint = "regorus_resource_schema_len", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_resource_schema_len(); + + /// + /// Check if the resource schema registry is empty. + /// + [DllImport(LibraryName, EntryPoint = "regorus_resource_schema_is_empty", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_resource_schema_is_empty(); + + /// + /// List all registered resource schema names as a JSON array. + /// + [DllImport(LibraryName, EntryPoint = "regorus_resource_schema_list_names", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_resource_schema_list_names(); + + /// + /// Remove a resource schema by name. + /// + [DllImport(LibraryName, EntryPoint = "regorus_resource_schema_remove", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_resource_schema_remove(byte* name); + + /// + /// Clear all resource schemas from the registry. + /// + [DllImport(LibraryName, EntryPoint = "regorus_resource_schema_clear", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_resource_schema_clear(); + + #endregion + + #region Effect Schema Registry Methods + + /// + /// Register an effect schema from JSON with a given name. + /// + [DllImport(LibraryName, EntryPoint = "regorus_effect_schema_register", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_effect_schema_register(byte* name, byte* schema_json); + + /// + /// Check if an effect schema with the given name exists. + /// + [DllImport(LibraryName, EntryPoint = "regorus_effect_schema_contains", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_effect_schema_contains(byte* name); + + /// + /// Get the number of registered effect schemas. + /// + [DllImport(LibraryName, EntryPoint = "regorus_effect_schema_len", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_effect_schema_len(); + + /// + /// Check if the effect schema registry is empty. + /// + [DllImport(LibraryName, EntryPoint = "regorus_effect_schema_is_empty", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_effect_schema_is_empty(); + + /// + /// List all registered effect schema names as a JSON array. + /// + [DllImport(LibraryName, EntryPoint = "regorus_effect_schema_list_names", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_effect_schema_list_names(); + + /// + /// Remove an effect schema by name. + /// + [DllImport(LibraryName, EntryPoint = "regorus_effect_schema_remove", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_effect_schema_remove(byte* name); + + /// + /// Clear all effect schemas from the registry. + /// + [DllImport(LibraryName, EntryPoint = "regorus_effect_schema_clear", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern RegorusResult regorus_effect_schema_clear(); + + #endregion + } + + #region Native Structures + + /// + /// Type of data contained in RegorusResult. + /// + internal enum RegorusDataType : uint + { + /// + /// No data / void. + /// + None, + /// + /// String data (output field is valid). + /// + String, + /// + /// Boolean data (bool_value field is valid). + /// + Boolean, + /// + /// Integer data (int_value field is valid). + /// + Integer, + /// + /// Pointer data (pointer_value field is valid). + /// + Pointer, + } + + /// + /// Status of a call on RegorusEngine. + /// + internal enum RegorusStatus : uint + { + /// + /// The operation was successful. + /// + Ok, + /// + /// The operation was unsuccessful. + /// + Error, + /// + /// Invalid data format provided. + /// + InvalidDataFormat, + /// + /// Invalid entrypoint rule specified. + /// + InvalidEntrypoint, + /// + /// Compilation failed. + /// + CompilationFailed, + /// + /// Invalid argument provided. + /// + InvalidArgument, + /// + /// Invalid module ID. + /// + InvalidModuleId, + /// + /// Invalid policy content. + /// + InvalidPolicy, + } + + /// + /// Result of a call on RegorusEngine. + /// Must be freed using regorus_result_drop. + /// + [StructLayout(LayoutKind.Sequential)] + internal unsafe partial struct RegorusResult + { + /// + /// Status. + /// + public RegorusStatus status; + /// + /// Type of data contained in this result. + /// + public RegorusDataType data_type; + /// + /// String output produced by the call. + /// Valid when data_type is String. Owned by Rust. + /// + public byte* output; + /// + /// Boolean value. + /// Valid when data_type is Boolean. + /// + public bool bool_value; + /// + /// Integer value. + /// Valid when data_type is Integer. + /// + public long int_value; + /// + /// Pointer value. + /// Valid when data_type is Pointer. + /// + public void* pointer_value; + /// + /// Errors produced by the call. + /// Owned by Rust. + /// + public byte* error_message; + } + + /// + /// Wrapper for regorus::Engine. + /// + [StructLayout(LayoutKind.Sequential)] + internal unsafe partial struct RegorusEngine + { + } + + /// + /// Wrapper for regorus::CompiledPolicy. + /// + [StructLayout(LayoutKind.Sequential)] + internal unsafe partial struct RegorusCompiledPolicy + { + } + + /// + /// FFI wrapper for PolicyModule struct. + /// + [StructLayout(LayoutKind.Sequential)] + internal unsafe partial struct RegorusPolicyModule + { + public byte* id; + public byte* content; + } + + #endregion +} diff --git a/bindings/csharp/Regorus/PolicyInfo.cs b/bindings/csharp/Regorus/PolicyInfo.cs new file mode 100644 index 00000000..8cbbfced --- /dev/null +++ b/bindings/csharp/Regorus/PolicyInfo.cs @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +#nullable enable +namespace Regorus +{ + /// + /// Information about a compiled policy, including metadata about modules, + /// target configuration, and resource types that the policy can evaluate. + /// + public class PolicyInfo + { + /// + /// List of module identifiers that were compiled into this policy. + /// Each module ID represents a unique policy module that contributes + /// rules, functions, or data to the compiled policy. + /// + [JsonPropertyName("module_ids")] + public List ModuleIds { get; set; } = new List(); + + /// + /// Name of the target configuration used during compilation, if any. + /// This indicates which target schema and validation rules were applied. + /// + [JsonPropertyName("target_name")] + public string? TargetName { get; set; } + + /// + /// List of resource types that this policy can evaluate. + /// For target-aware policies, this contains the inferred or configured + /// resource types. For general policies, this may be empty. + /// + [JsonPropertyName("applicable_resource_types")] + public List ApplicableResourceTypes { get; set; } = new List(); + + /// + /// The primary rule or entrypoint that this policy evaluates. + /// This is the rule path that will be executed when the policy runs. + /// + [JsonPropertyName("entrypoint_rule")] + public string EntrypointRule { get; set; } = string.Empty; + + /// + /// The effect rule name for target-aware policies, if applicable. + /// This is the specific effect rule (e.g., "effect", "allow", "deny") + /// that determines the policy decision for target evaluation. + /// + [JsonPropertyName("effect_rule")] + public string? EffectRule { get; set; } + + /// + /// Parameters that can be configured for this policy. + /// Contains parameter names and their expected types or default values. + /// Used for parameterized policies that accept configuration at evaluation time. + /// Each element represents parameters from a different module. + /// + [JsonPropertyName("parameters")] + public List Parameters { get; set; } = new List(); + } + + /// + /// Parameters that can be configured for a policy. + /// + public class PolicyParameters + { + /// + /// Source file where the parameters are defined. + /// + [JsonPropertyName("source_file")] + public string SourceFile { get; set; } = string.Empty; + + /// + /// List of parameter definitions. + /// + [JsonPropertyName("parameters")] + public List Parameters { get; set; } = new List(); + + /// + /// List of parameter modifiers. + /// + [JsonPropertyName("modifiers")] + public List Modifiers { get; set; } = new List(); + } + + /// + /// A single parameter definition. + /// + public class PolicyParameter + { + /// + /// Name of the parameter. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// + /// Type of the parameter. + /// + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + /// + /// Default value of the parameter, if any. + /// + [JsonPropertyName("default")] + public object? Default { get; set; } + + /// + /// Description of the parameter. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// + /// Allowed values for the parameter, if constrained. + /// + [JsonPropertyName("allowed_values")] + public List? AllowedValues { get; set; } + } + + /// + /// A parameter modifier that affects parameter behavior. + /// + public class PolicyParameterModifier + { + /// + /// Name of the modifier. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// + /// Value of the modifier. + /// + [JsonPropertyName("value")] + public object? Value { get; set; } + } +} diff --git a/bindings/csharp/Regorus/Regorus.csproj b/bindings/csharp/Regorus/Regorus.csproj index cbe64206..da8e3f02 100644 --- a/bindings/csharp/Regorus/Regorus.csproj +++ b/bindings/csharp/Regorus/Regorus.csproj @@ -13,6 +13,10 @@ README.md + + + + - + diff --git a/bindings/csharp/Regorus/RegorusFFI.cs b/bindings/csharp/Regorus/RegorusFFI.cs deleted file mode 100644 index c2c2c9b2..00000000 --- a/bindings/csharp/Regorus/RegorusFFI.cs +++ /dev/null @@ -1,244 +0,0 @@ -// -// This code is generated by csbindgen. -// DON'T CHANGE THIS DIRECTLY. -// -#pragma warning disable CS8500 -#pragma warning disable CS8981 -using System; -using System.Runtime.InteropServices; - - -namespace Regorus.Internal -{ - internal static unsafe partial class API - { - const string __DllName = "regorus_ffi"; - - - - /// - /// Drop a `RegorusResult`. - /// - /// `output` and `error_message` strings are not valid after drop. - /// - [DllImport(__DllName, EntryPoint = "regorus_result_drop", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern void regorus_result_drop(RegorusResult r); - - /// - /// Construct a new Engine - /// - /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html - /// - [DllImport(__DllName, EntryPoint = "regorus_engine_new", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusEngine* regorus_engine_new(); - - /// - /// Clone a [`RegorusEngine`] - /// - /// To avoid having to parse same policy again, the engine can be cloned - /// after policies and data have been added. - /// - /// - [DllImport(__DllName, EntryPoint = "regorus_engine_clone", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusEngine* regorus_engine_clone(RegorusEngine* engine); - - [DllImport(__DllName, EntryPoint = "regorus_engine_drop", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern void regorus_engine_drop(RegorusEngine* engine); - - /// - /// Add a policy - /// - /// The policy is parsed into AST. - /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.add_policy - /// - /// * `path`: A filename to be associated with the policy. - /// * `rego`: Rego policy. - /// - [DllImport(__DllName, EntryPoint = "regorus_engine_add_policy", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusResult regorus_engine_add_policy(RegorusEngine* engine, byte* path, byte* rego); - - [DllImport(__DllName, EntryPoint = "regorus_engine_add_policy_from_file", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusResult regorus_engine_add_policy_from_file(RegorusEngine* engine, byte* path); - - /// - /// Add policy data. - /// - /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.add_data - /// * `data`: JSON encoded value to be used as policy data. - /// - [DllImport(__DllName, EntryPoint = "regorus_engine_add_data_json", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusResult regorus_engine_add_data_json(RegorusEngine* engine, byte* data); - - /// - /// Get list of loaded Rego packages as JSON. - /// - /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.get_packages - /// - [DllImport(__DllName, EntryPoint = "regorus_engine_get_packages", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusResult regorus_engine_get_packages(RegorusEngine* engine); - - /// - /// Get list of policies as JSON. - /// - /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.get_policies - /// - [DllImport(__DllName, EntryPoint = "regorus_engine_get_policies", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusResult regorus_engine_get_policies(RegorusEngine* engine); - - [DllImport(__DllName, EntryPoint = "regorus_engine_add_data_from_json_file", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusResult regorus_engine_add_data_from_json_file(RegorusEngine* engine, byte* path); - - /// - /// Clear policy data. - /// - /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.clear_data - /// - [DllImport(__DllName, EntryPoint = "regorus_engine_clear_data", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusResult regorus_engine_clear_data(RegorusEngine* engine); - - /// - /// Set input. - /// - /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.set_input - /// * `input`: JSON encoded value to be used as input to query. - /// - [DllImport(__DllName, EntryPoint = "regorus_engine_set_input_json", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusResult regorus_engine_set_input_json(RegorusEngine* engine, byte* input); - - [DllImport(__DllName, EntryPoint = "regorus_engine_set_input_from_json_file", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusResult regorus_engine_set_input_from_json_file(RegorusEngine* engine, byte* path); - - /// - /// Evaluate query. - /// - /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.eval_query - /// * `query`: Rego expression to be evaluate. - /// - [DllImport(__DllName, EntryPoint = "regorus_engine_eval_query", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusResult regorus_engine_eval_query(RegorusEngine* engine, byte* query); - - /// - /// Evaluate specified rule. - /// - /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.eval_rule - /// * `rule`: Path to the rule. - /// - [DllImport(__DllName, EntryPoint = "regorus_engine_eval_rule", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusResult regorus_engine_eval_rule(RegorusEngine* engine, byte* rule); - - /// - /// Enable/disable coverage. - /// - /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.set_enable_coverage - /// * `enable`: Whether to enable or disable coverage. - /// - [DllImport(__DllName, EntryPoint = "regorus_engine_set_enable_coverage", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusResult regorus_engine_set_enable_coverage(RegorusEngine* engine, [MarshalAs(UnmanagedType.U1)] bool enable); - - /// - /// Get coverage report. - /// - /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.get_coverage_report - /// - [DllImport(__DllName, EntryPoint = "regorus_engine_get_coverage_report", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusResult regorus_engine_get_coverage_report(RegorusEngine* engine); - - /// - /// Enable/disable strict builtin errors. - /// - /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.set_strict_builtin_errors - /// * `strict`: Whether to raise errors or return undefined on certain scenarios. - /// - [DllImport(__DllName, EntryPoint = "regorus_engine_set_strict_builtin_errors", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusResult regorus_engine_set_strict_builtin_errors(RegorusEngine* engine, [MarshalAs(UnmanagedType.U1)] bool strict); - - /// - /// Get pretty printed coverage report. - /// - /// See https://docs.rs/regorus/latest/regorus/coverage/struct.Report.html#method.to_string_pretty - /// - [DllImport(__DllName, EntryPoint = "regorus_engine_get_coverage_report_pretty", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusResult regorus_engine_get_coverage_report_pretty(RegorusEngine* engine); - - /// - /// Clear coverage data. - /// - /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.clear_coverage_data - /// - [DllImport(__DllName, EntryPoint = "regorus_engine_clear_coverage_data", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusResult regorus_engine_clear_coverage_data(RegorusEngine* engine); - - /// - /// Whether to gather output of print statements. - /// - /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.set_gather_prints - /// * `enable`: Whether to enable or disable gathering print statements. - /// - [DllImport(__DllName, EntryPoint = "regorus_engine_set_gather_prints", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusResult regorus_engine_set_gather_prints(RegorusEngine* engine, [MarshalAs(UnmanagedType.U1)] bool enable); - - /// - /// Take all the gathered print statements. - /// - /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.take_prints - /// - [DllImport(__DllName, EntryPoint = "regorus_engine_take_prints", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusResult regorus_engine_take_prints(RegorusEngine* engine); - - /// - /// Get AST of policies. - /// - /// See https://docs.rs/regorus/latest/regorus/coverage/struct.Engine.html#method.get_ast_as_json - /// - [DllImport(__DllName, EntryPoint = "regorus_engine_get_ast_as_json", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusResult regorus_engine_get_ast_as_json(RegorusEngine* engine); - - /// - /// Gets the package names of policies added to the engine. - /// - /// See https://docs.rs/regorus/latest/regorus/coverage/struct.Engine.html#method.get_policy_package_names - /// - [DllImport(__DllName, EntryPoint = "regorus_engine_get_policy_package_names", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusResult regorus_engine_get_policy_package_names(RegorusEngine* engine); - - /// - /// Gets the parameters defined in each policy added to the engine - /// - /// See https://docs.rs/regorus/latest/regorus/coverage/struct.Engine.html#method.get_policy_parameters - /// - [DllImport(__DllName, EntryPoint = "regorus_engine_get_policy_parameters", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusResult regorus_engine_get_policy_parameters(RegorusEngine* engine); - - /// - /// Enable/disable rego v1. - /// - /// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.set_rego_v0 - /// - [DllImport(__DllName, EntryPoint = "regorus_engine_set_rego_v0", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] - internal static extern RegorusResult regorus_engine_set_rego_v0(RegorusEngine* engine, [MarshalAs(UnmanagedType.U1)] bool enable); - - - } - - [StructLayout(LayoutKind.Sequential)] - internal unsafe partial struct RegorusResult - { - public RegorusStatus status; - public byte* output; - public byte* error_message; - } - - [StructLayout(LayoutKind.Sequential)] - internal unsafe partial struct RegorusEngine - { - } - - - internal enum RegorusStatus : uint - { - RegorusStatusOk, - RegorusStatusError, - } - - -} diff --git a/bindings/csharp/Regorus/SchemaRegistry.cs b/bindings/csharp/Regorus/SchemaRegistry.cs new file mode 100644 index 00000000..2c061dc5 --- /dev/null +++ b/bindings/csharp/Regorus/SchemaRegistry.cs @@ -0,0 +1,284 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Text; + +#nullable enable +namespace Regorus +{ + /// + /// Provides static methods for managing the global resource schema registry. + /// Resource schemas define the structure and validation rules for Azure Policy resources. + /// + public static unsafe class SchemaRegistry + { + /// + /// Register a resource schema from JSON with a given name. + /// + /// Name to register the schema under + /// JSON string representing the schema + /// Thrown when schema registration fails + public static void RegisterResource(string name, string schemaJson) + { + var nameBytes = Encoding.UTF8.GetBytes(name + char.MinValue); + var schemaBytes = Encoding.UTF8.GetBytes(schemaJson + char.MinValue); + + fixed (byte* namePtr = nameBytes) + fixed (byte* schemaPtr = schemaBytes) + { + CheckAndDropResult(Internal.API.regorus_resource_schema_register(namePtr, schemaPtr)); + } + } + + /// + /// Check if a resource schema with the given name exists. + /// + /// Name of the schema to check + /// True if the schema exists, false otherwise + /// Thrown when the operation fails + public static bool ContainsResource(string name) + { + var nameBytes = Encoding.UTF8.GetBytes(name + char.MinValue); + fixed (byte* namePtr = nameBytes) + { + var result = Internal.API.regorus_resource_schema_contains(namePtr); + return GetBoolResult(result); + } + } + + /// + /// Get the number of registered resource schemas. + /// + /// The number of registered resource schemas + /// Thrown when the operation fails + public static long ResourceCount + { + get + { + var result = Internal.API.regorus_resource_schema_len(); + return GetIntResult(result); + } + } + + /// + /// Check if the resource schema registry is empty. + /// + /// True if the registry is empty, false otherwise + /// Thrown when the operation fails + public static bool IsResourceRegistryEmpty + { + get + { + var result = Internal.API.regorus_resource_schema_is_empty(); + return GetBoolResult(result); + } + } + + /// + /// List all registered resource schema names. + /// + /// JSON array of schema names + /// Thrown when the operation fails + public static string ListResourceNames() + { + return CheckAndDropResult(Internal.API.regorus_resource_schema_list_names()) ?? "[]"; + } + + /// + /// Remove a resource schema by name. + /// + /// Name of the schema to remove + /// True if the schema was removed, false if it wasn't found + /// Thrown when the operation fails + public static bool RemoveResource(string name) + { + var nameBytes = Encoding.UTF8.GetBytes(name + char.MinValue); + fixed (byte* namePtr = nameBytes) + { + var result = Internal.API.regorus_resource_schema_remove(namePtr); + return GetBoolResult(result); + } + } + + /// + /// Clear all resource schemas from the registry. + /// + /// Thrown when the operation fails + public static void ClearResources() + { + CheckAndDropResult(Internal.API.regorus_resource_schema_clear()); + } + + /// + /// Register an effect schema from JSON with a given name. + /// + /// Name to register the schema under + /// JSON string representing the schema + /// Thrown when schema registration fails + public static void RegisterEffect(string name, string schemaJson) + { + var nameBytes = Encoding.UTF8.GetBytes(name + char.MinValue); + var schemaBytes = Encoding.UTF8.GetBytes(schemaJson + char.MinValue); + + fixed (byte* namePtr = nameBytes) + fixed (byte* schemaPtr = schemaBytes) + { + CheckAndDropResult(Internal.API.regorus_effect_schema_register(namePtr, schemaPtr)); + } + } + + /// + /// Check if an effect schema with the given name exists. + /// + /// Name of the schema to check + /// True if the schema exists, false otherwise + /// Thrown when the operation fails + public static bool ContainsEffect(string name) + { + var nameBytes = Encoding.UTF8.GetBytes(name + char.MinValue); + fixed (byte* namePtr = nameBytes) + { + var result = Internal.API.regorus_effect_schema_contains(namePtr); + return GetBoolResult(result); + } + } + + /// + /// Get the number of registered effect schemas. + /// + /// The number of registered effect schemas + /// Thrown when the operation fails + public static long EffectCount + { + get + { + var result = Internal.API.regorus_effect_schema_len(); + return GetIntResult(result); + } + } + + /// + /// Check if the effect schema registry is empty. + /// + /// True if the registry is empty, false otherwise + /// Thrown when the operation fails + public static bool IsEffectRegistryEmpty + { + get + { + var result = Internal.API.regorus_effect_schema_is_empty(); + return GetBoolResult(result); + } + } + + /// + /// List all registered effect schema names. + /// + /// JSON array of schema names + /// Thrown when the operation fails + public static string ListEffectNames() + { + return CheckAndDropResult(Internal.API.regorus_effect_schema_list_names()) ?? "[]"; + } + + /// + /// Remove an effect schema by name. + /// + /// Name of the schema to remove + /// True if the schema was removed, false if it wasn't found + /// Thrown when the operation fails + public static bool RemoveEffect(string name) + { + var nameBytes = Encoding.UTF8.GetBytes(name + char.MinValue); + fixed (byte* namePtr = nameBytes) + { + var result = Internal.API.regorus_effect_schema_remove(namePtr); + return GetBoolResult(result); + } + } + + /// + /// Clear all effect schemas from the registry. + /// + /// Thrown when the operation fails + public static void ClearEffects() + { + CheckAndDropResult(Internal.API.regorus_effect_schema_clear()); + } + + private static string? StringFromUTF8(IntPtr ptr) + { +#if NETSTANDARD2_1 + return System.Runtime.InteropServices.Marshal.PtrToStringUTF8(ptr); +#else + int len = 0; + while (System.Runtime.InteropServices.Marshal.ReadByte(ptr, len) != 0) { ++len; } + byte[] buffer = new byte[len]; + System.Runtime.InteropServices.Marshal.Copy(ptr, buffer, 0, buffer.Length); + return Encoding.UTF8.GetString(buffer); +#endif + } + + private static string? CheckAndDropResult(Internal.RegorusResult result) + { + try + { + if (result.status != Internal.RegorusStatus.Ok) + { + var message = StringFromUTF8((IntPtr)result.error_message); + throw new Exception(message ?? "Unknown error occurred"); + } + + return result.data_type switch + { + Internal.RegorusDataType.String => StringFromUTF8((IntPtr)result.output), + Internal.RegorusDataType.Boolean => result.bool_value.ToString().ToLowerInvariant(), + Internal.RegorusDataType.Integer => result.int_value.ToString(), + Internal.RegorusDataType.None => null, + _ => StringFromUTF8((IntPtr)result.output) + }; + } + finally + { + Internal.API.regorus_result_drop(result); + } + } + + private static bool GetBoolResult(Internal.RegorusResult result) + { + try + { + if (result.status != Internal.RegorusStatus.Ok) + { + var message = StringFromUTF8((IntPtr)result.error_message); + throw new Exception(message ?? "Unknown error occurred"); + } + + return result.data_type == Internal.RegorusDataType.Boolean ? result.bool_value : false; + } + finally + { + Internal.API.regorus_result_drop(result); + } + } + + private static long GetIntResult(Internal.RegorusResult result) + { + try + { + if (result.status != Internal.RegorusStatus.Ok) + { + var message = StringFromUTF8((IntPtr)result.error_message); + throw new Exception(message ?? "Unknown error occurred"); + } + + return result.data_type == Internal.RegorusDataType.Integer ? result.int_value : 0; + } + finally + { + Internal.API.regorus_result_drop(result); + } + } + } +} diff --git a/bindings/csharp/Regorus/TargetRegistry.cs b/bindings/csharp/Regorus/TargetRegistry.cs new file mode 100644 index 00000000..25ee1079 --- /dev/null +++ b/bindings/csharp/Regorus/TargetRegistry.cs @@ -0,0 +1,185 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Text; + +#nullable enable +namespace Regorus +{ + /// + /// Provides static methods for managing the global target registry. + /// Targets define resource types and their associated schemas for Azure Policy evaluation. + /// + public static unsafe class TargetRegistry + { + /// + /// Register a target from JSON definition. + /// The target JSON should follow the target schema format. + /// Once registered, the target can be referenced in Rego policies using `__target__` rules. + /// + /// JSON encoded target definition + /// Thrown when target registration fails + public static void RegisterFromJson(string targetJson) + { + var targetBytes = Encoding.UTF8.GetBytes(targetJson + char.MinValue); + fixed (byte* targetPtr = targetBytes) + { + CheckAndDropResult(Internal.API.regorus_register_target_from_json(targetPtr)); + } + } + + /// + /// Check if a target is registered. + /// + /// Name of the target to check + /// True if the target is registered, false otherwise + /// Thrown when the operation fails + public static bool Contains(string name) + { + var nameBytes = Encoding.UTF8.GetBytes(name + char.MinValue); + fixed (byte* namePtr = nameBytes) + { + var result = Internal.API.regorus_target_registry_contains(namePtr); + return GetBoolResult(result); + } + } + + /// + /// Get a list of all registered target names. + /// + /// JSON array of target names + /// Thrown when the operation fails + public static string ListNames() + { + return CheckAndDropResult(Internal.API.regorus_target_registry_list_names()) ?? "[]"; + } + + /// + /// Remove a target from the registry by name. + /// + /// The target name to remove + /// True if the target was removed, false if it wasn't found + /// Thrown when the operation fails + public static bool Remove(string name) + { + var nameBytes = Encoding.UTF8.GetBytes(name + char.MinValue); + fixed (byte* namePtr = nameBytes) + { + var result = Internal.API.regorus_target_registry_remove(namePtr); + return GetBoolResult(result); + } + } + + /// + /// Clear all targets from the registry. + /// + /// Thrown when the operation fails + public static void Clear() + { + CheckAndDropResult(Internal.API.regorus_target_registry_clear()); + } + + /// + /// Get the number of registered targets. + /// + /// The number of registered targets + /// Thrown when the operation fails + public static long Count + { + get + { + var result = Internal.API.regorus_target_registry_len(); + return GetIntResult(result); + } + } + + /// + /// Check if the target registry is empty. + /// + /// True if the registry is empty, false otherwise + /// Thrown when the operation fails + public static bool IsEmpty + { + get + { + var result = Internal.API.regorus_target_registry_is_empty(); + return GetBoolResult(result); + } + } + + private static string? StringFromUTF8(IntPtr ptr) + { +#if NETSTANDARD2_1 + return System.Runtime.InteropServices.Marshal.PtrToStringUTF8(ptr); +#else + int len = 0; + while (System.Runtime.InteropServices.Marshal.ReadByte(ptr, len) != 0) { ++len; } + byte[] buffer = new byte[len]; + System.Runtime.InteropServices.Marshal.Copy(ptr, buffer, 0, buffer.Length); + return Encoding.UTF8.GetString(buffer); +#endif + } + + private static string? CheckAndDropResult(Internal.RegorusResult result) + { + try + { + if (result.status != Internal.RegorusStatus.Ok) + { + var message = StringFromUTF8((IntPtr)result.error_message); + throw new Exception(message ?? "Unknown error occurred"); + } + + return result.data_type switch + { + Internal.RegorusDataType.String => StringFromUTF8((IntPtr)result.output), + Internal.RegorusDataType.Boolean => result.bool_value.ToString().ToLowerInvariant(), + Internal.RegorusDataType.Integer => result.int_value.ToString(), + Internal.RegorusDataType.None => null, + _ => StringFromUTF8((IntPtr)result.output) + }; + } + finally + { + Internal.API.regorus_result_drop(result); + } + } + + private static bool GetBoolResult(Internal.RegorusResult result) + { + try + { + if (result.status != Internal.RegorusStatus.Ok) + { + var message = StringFromUTF8((IntPtr)result.error_message); + throw new Exception(message ?? "Unknown error occurred"); + } + + return result.data_type == Internal.RegorusDataType.Boolean ? result.bool_value : false; + } + finally + { + Internal.API.regorus_result_drop(result); + } + } + + private static long GetIntResult(Internal.RegorusResult result) + { + try + { + if (result.status != Internal.RegorusStatus.Ok) + { + var message = StringFromUTF8((IntPtr)result.error_message); + throw new Exception(message ?? "Unknown error occurred"); + } + + return result.data_type == Internal.RegorusDataType.Integer ? result.int_value : 0; + } + finally + { + Internal.API.regorus_result_drop(result); + } + } + } +} diff --git a/bindings/csharp/TargetExampleApp/Program.cs b/bindings/csharp/TargetExampleApp/Program.cs new file mode 100644 index 00000000..b6a15481 --- /dev/null +++ b/bindings/csharp/TargetExampleApp/Program.cs @@ -0,0 +1,291 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; + +namespace TargetExampleApp; + +class Program +{ + // Policy definition constants + private const string AZURE_STORAGE_POLICY_DEFINITION = @" +package policy + +import rego.v1 + +# Target declaration for Azure Policy +__target__ := ""target.tests.azure_policy"" + +default parameters.requiredTLSVersion = """" +default parameters.allowedPorts = [] + +# Policy rules for storage accounts +default allow := false + +# Allow storage accounts with HTTPS-only traffic and proper encryption +allow if { + input.type == ""Microsoft.Storage/storageAccounts"" + input.properties.supportsHttpsTrafficOnly == true + input.properties.encryption.services.blob.enabled == true + input.properties.minimumTlsVersion in [parameters.requiredTLSVersion] +} + +# Allow network security groups with proper inbound rules +allow if { + input.type == ""Microsoft.Network/networkSecurityGroups"" + count([rule | + rule := input.properties.securityRules[_] + rule.properties.direction == ""Inbound"" + rule.properties.access == ""Allow"" + rule.properties.sourceAddressPrefix == ""*"" + rule.properties.destinationPortRange in [parameters.allowedPorts] + ]) == 0 +}"; + + private const string AZURE_STORAGE_POLICY_ASSIGNMENT = @" +package policy + +import rego.v1 + +parameters.requiredTLSVersion = ""TLS1_2"" +parameters.allowedPorts = [""22"", ""3389""]"; + + // Test data constants + private const string COMPLIANT_STORAGE_ACCOUNT = @"{ + ""type"": ""Microsoft.Storage/storageAccounts"", + ""name"": ""compliantstorageacct"", + ""location"": ""eastus"", + ""kind"": ""StorageV2"", + ""properties"": { + ""supportsHttpsTrafficOnly"": true, + ""minimumTlsVersion"": ""TLS1_2"", + ""allowBlobPublicAccess"": false, + ""encryption"": { + ""services"": { + ""blob"": { ""enabled"": true }, + ""file"": { ""enabled"": true } + } + } + }, + ""tags"": { + ""environment"": ""production"" + } +}"; + + private const string NON_COMPLIANT_STORAGE_ACCOUNT = @"{ + ""type"": ""Microsoft.Storage/storageAccounts"", + ""name"": ""insecurestorageacct"", + ""location"": ""westus"", + ""kind"": ""Storage"", + ""properties"": { + ""supportsHttpsTrafficOnly"": false, + ""minimumTlsVersion"": ""TLS1_0"", + ""allowBlobPublicAccess"": true, + ""encryption"": { + ""services"": { + ""blob"": { ""enabled"": false }, + ""file"": { ""enabled"": false } + } + } + } +}"; + + static void Main(string[] args) + { + Console.WriteLine("=== Regorus Target Example Application ===\n"); + + try + { + DemonstrateTargetFunctionality(); + Console.WriteLine("\n=== Target demonstration completed successfully! ==="); + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + Environment.Exit(1); + } + } + + static void DemonstrateTargetFunctionality() + { + Console.WriteLine("REGORUS TARGET FUNCTIONALITY DEMONSTRATION"); + Console.WriteLine("=========================================="); + + // 1. Register target using JSON from file + var targetJsonPath = Path.Combine(AppContext.BaseDirectory, "azure_policy.target.json"); + var targetJson = File.ReadAllText(targetJsonPath); + + Console.WriteLine("1. Registering target from JSON file:"); + Console.WriteLine(targetJson); + + Regorus.TargetRegistry.RegisterFromJson(targetJson); + Console.WriteLine($"Target registered. Registry contains {Regorus.TargetRegistry.Count} target(s)"); + Console.WriteLine($"Registered targets: {Regorus.TargetRegistry.ListNames()}"); + + // 2. Compile policy for target + var policyModules = new List + { + new Regorus.PolicyModule($"definition-{Guid.NewGuid():N}", AZURE_STORAGE_POLICY_DEFINITION), + new Regorus.PolicyModule($"assignment-{Guid.NewGuid():N}", AZURE_STORAGE_POLICY_ASSIGNMENT) + }; + + var policyDataJson = "{}"; + + Console.WriteLine("\n2. Compiling policy for target..."); + using var compiledPolicy = Regorus.Compiler.CompilePolicyForTarget(policyDataJson, policyModules); + Console.WriteLine("Policy compiled successfully!"); + + // 2.5. Demonstrate policy information retrieval + Console.WriteLine("\n2.5. Retrieving policy information:"); + DemonstratePolicyInfo(compiledPolicy); + + // 3. Evaluate with different inputs + Console.WriteLine("\n3. Testing policy evaluation:"); + Console.WriteLine("Compliant storage account:"); + Console.WriteLine(COMPLIANT_STORAGE_ACCOUNT); + + var compliantResult = compiledPolicy.EvalWithInput(COMPLIANT_STORAGE_ACCOUNT); + Console.WriteLine($"Result: {compliantResult}"); + + Console.WriteLine("\nNon-compliant storage account:"); + Console.WriteLine(NON_COMPLIANT_STORAGE_ACCOUNT); + + var nonCompliantResult = compiledPolicy.EvalWithInput(NON_COMPLIANT_STORAGE_ACCOUNT); + Console.WriteLine($"Result: {nonCompliantResult}"); + + // 4. Demonstrate thread-safe concurrent evaluation + Console.WriteLine("\n4. Testing concurrent evaluation from multiple threads:"); + DemonstrateConcurrentEvaluation(compiledPolicy); + } + + static void DemonstrateConcurrentEvaluation(Regorus.CompiledPolicy compiledPolicy) + { + var testInputs = new[] + { + ("Thread-1-Compliant", COMPLIANT_STORAGE_ACCOUNT), + ("Thread-2-NonCompliant", NON_COMPLIANT_STORAGE_ACCOUNT), + ("Thread-3-Compliant", COMPLIANT_STORAGE_ACCOUNT.Replace("compliantstorageacct", "thread3storage")), + ("Thread-4-NonCompliant", NON_COMPLIANT_STORAGE_ACCOUNT.Replace("insecurestorageacct", "thread4storage")), + ("Thread-5-Compliant", COMPLIANT_STORAGE_ACCOUNT.Replace("compliantstorageacct", "thread5storage")) + }; + + Console.WriteLine($"Starting {testInputs.Length} concurrent evaluations..."); + + var tasks = testInputs.Select(input => + Task.Run(() => { + var (threadName, json) = input; + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + // Multiple evaluations per thread to stress test + var results = new List(); + for (int i = 0; i < 1000; i++) + { + var result = compiledPolicy.EvalWithInput(json); + results.Add(result); + } + + stopwatch.Stop(); + var microseconds = stopwatch.ElapsedTicks * 1000000 / System.Diagnostics.Stopwatch.Frequency; + + // Verify all results are identical (thread safety) + var firstResult = results[0]; + var allIdentical = results.All(r => r == firstResult); + + Console.WriteLine($"✓ {threadName}: {results.Count} evaluations in {microseconds}μs, " + + $"Results consistent: {allIdentical}"); + + return (threadName, results.Count, microseconds, allIdentical); + }) + ).ToArray(); + + // Wait for all threads to complete + var results = Task.WhenAll(tasks).Result; + + Console.WriteLine("\nConcurrency test results:"); + var totalEvaluations = results.Sum(r => r.Item2); + var maxTime = results.Max(r => r.Item3); + var allConsistent = results.All(r => r.allIdentical); + + Console.WriteLine($"✓ Total evaluations: {totalEvaluations}"); + Console.WriteLine($"✓ Max thread time: {maxTime}μs"); + Console.WriteLine($"✓ All threads consistent: {allConsistent}"); + Console.WriteLine($"✓ Approximate throughput: {totalEvaluations * 1000000.0 / maxTime:F0} evaluations/second"); + Console.WriteLine("✓ No locks required - CompiledPolicy is thread-safe!"); + } + + static void DemonstratePolicyInfo(Regorus.CompiledPolicy compiledPolicy) + { + Console.WriteLine("Getting policy metadata using GetPolicyInfo()..."); + + try + { + var policyInfo = compiledPolicy.GetPolicyInfo(); + + Console.WriteLine($"✓ Policy Information Retrieved:"); + Console.WriteLine($" Target Name: {policyInfo.TargetName ?? "None"}"); + Console.WriteLine($" Effect Rule: {policyInfo.EffectRule ?? "None"}"); + Console.WriteLine($" Entrypoint Rule: {policyInfo.EntrypointRule}"); + + Console.WriteLine($" Module IDs ({policyInfo.ModuleIds.Count}):"); + foreach (var moduleId in policyInfo.ModuleIds) + { + Console.WriteLine($" - {moduleId}"); + } + + Console.WriteLine($" Applicable Resource Types ({policyInfo.ApplicableResourceTypes.Count}):"); + foreach (var resourceType in policyInfo.ApplicableResourceTypes) + { + Console.WriteLine($" - {resourceType}"); + } + + if (policyInfo.Parameters != null && policyInfo.Parameters.Count > 0) + { + Console.WriteLine($" Policy Parameters:"); + foreach (var parameterSet in policyInfo.Parameters) + { + Console.WriteLine($" From '{parameterSet.SourceFile}':"); + Console.WriteLine($" Parameters ({parameterSet.Parameters.Count}):"); + foreach (var param in parameterSet.Parameters) + { + Console.WriteLine($" - {param.Name} ({param.Type})"); + if (param.Default != null) + { + Console.WriteLine($" Default: {param.Default}"); + } + if (!string.IsNullOrEmpty(param.Description)) + { + Console.WriteLine($" Description: {param.Description}"); + } + } + + if (parameterSet.Modifiers.Count > 0) + { + Console.WriteLine($" Modifiers ({parameterSet.Modifiers.Count}):"); + foreach (var modifier in parameterSet.Modifiers) + { + Console.WriteLine($" - {modifier.Name}: {modifier.Value}"); + } + } + } + } + else + { + Console.WriteLine(" No parameter information available"); + } + + // Demonstrate JSON serialization of policy info + Console.WriteLine("\n✓ Policy Info as JSON:"); + var jsonOptions = new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + var policyInfoJson = JsonSerializer.Serialize(policyInfo, jsonOptions); + Console.WriteLine(policyInfoJson); + } + catch (Exception ex) + { + Console.WriteLine($"✗ Failed to get policy info: {ex.Message}"); + } + } +} diff --git a/bindings/csharp/TargetExampleApp/TargetExampleApp.csproj b/bindings/csharp/TargetExampleApp/TargetExampleApp.csproj new file mode 100644 index 00000000..f39db73d --- /dev/null +++ b/bindings/csharp/TargetExampleApp/TargetExampleApp.csproj @@ -0,0 +1,26 @@ + + + + Exe + net8.0 + enable + enable + true + + + + + -$(VersionSuffix) + + + + + + + + + PreserveNewest + + + + diff --git a/bindings/csharp/TargetExampleApp/azure_policy.target.json b/bindings/csharp/TargetExampleApp/azure_policy.target.json new file mode 100644 index 00000000..cb6126a2 --- /dev/null +++ b/bindings/csharp/TargetExampleApp/azure_policy.target.json @@ -0,0 +1,125 @@ +{ + "name": "target.tests.azure_policy", + "description": "Azure Policy target for comprehensive policy evaluation testing", + "version": "1.0.0", + "resource_schema_selector": "type", + "resource_schemas": [ + { + "type": "object", + "properties": { + "type": { "const": "Microsoft.Resources/subscriptions" }, + "subscriptionId": { "type": "string" }, + "tenantId": { "type": "string" }, + "displayName": { "type": "string" } + }, + "required": ["type", "subscriptionId"] + }, + { + "type": "object", + "properties": { + "type": { "const": "Microsoft.Storage/storageAccounts" }, + "name": { "type": "string" }, + "location": { "type": "string" }, + "kind": { "enum": ["Storage", "StorageV2", "BlobStorage", "FileStorage", "BlockBlobStorage"] }, + "properties": { + "type": "object", + "properties": { + "supportsHttpsTrafficOnly": { "type": "boolean" }, + "minimumTlsVersion": { "enum": ["TLS1_0", "TLS1_1", "TLS1_2"] }, + "allowBlobPublicAccess": { "type": "boolean" }, + "encryption": { + "type": "object", + "properties": { + "services": { + "type": "object", + "properties": { + "blob": { "type": "object", "properties": { "enabled": { "type": "boolean" } } }, + "file": { "type": "object", "properties": { "enabled": { "type": "boolean" } } } + } + } + } + } + } + }, + "tags": { "type": "object" } + }, + "required": ["type", "name", "location"] + }, + { + "type": "object", + "properties": { + "type": { "const": "Microsoft.Network/networkSecurityGroups" }, + "name": { "type": "string" }, + "location": { "type": "string" }, + "properties": { + "type": "object", + "properties": { + "securityRules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "properties": { + "type": "object", + "properties": { + "direction": { "enum": ["Inbound", "Outbound"] }, + "access": { "enum": ["Allow", "Deny"] }, + "protocol": { "enum": ["Tcp", "Udp", "*"] }, + "sourcePortRange": { "type": "string" }, + "destinationPortRange": { "type": "string" }, + "sourceAddressPrefix": { "type": "string" }, + "destinationAddressPrefix": { "type": "string" }, + "priority": { "type": "integer", "minimum": 100, "maximum": 4096 } + } + } + } + } + } + } + } + }, + "required": ["type", "name", "location"] + } + ], + "effects": { + "allow": { "type": "boolean" }, + "deny": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + }, + "audit": { + "type": "object", + "properties": { + "level": { "enum": ["info", "warning", "error"] }, + "message": { "type": "string" }, + "complianceState": { "enum": ["Compliant", "NonCompliant", "Unknown"] } + } + }, + "modify": { + "type": "object", + "properties": { + "operations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "operation": { "enum": ["add", "replace", "remove"] }, + "field": { "type": "string" }, + "value": { "type": "any" } + } + } + } + } + }, + "deployIfNotExists": { + "type": "object", + "properties": { + "template": { "type": "object" }, + "parameters": { "type": "object" } + } + } + } +} diff --git a/bindings/csharp/global.json b/bindings/csharp/global.json index 559ff1b3..b7c856bf 100644 --- a/bindings/csharp/global.json +++ b/bindings/csharp/global.json @@ -5,6 +5,6 @@ "sdk": { "allowPrerelease": false, "version": "8.0.412", - "rollForward": "disable" + "rollForward": "latestFeature" } } \ No newline at end of file diff --git a/bindings/ffi/Cargo.lock b/bindings/ffi/Cargo.lock index b53a4990..97b5558b 100644 --- a/bindings/ffi/Cargo.lock +++ b/bindings/ffi/Cargo.lock @@ -92,9 +92,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" [[package]] name = "autocfg" @@ -217,18 +217,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.43" +version = "4.5.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50fd97c9dc2399518aa331917ac6f274280ec5eb34e555dd291899745c48ec6f" +checksum = "1fc0e74a703892159f5ae7d3aac52c8e6c392f5ae5f359c70b5881d60aaac318" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.43" +version = "4.5.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c35b5830294e1fa0462034af85cc95225a4cb07092c088c55bda3147cfcd8f65" +checksum = "b3e7f4214277f3c7aa526a59dd3fbe306a370daee1f8b7b8c987069cd8e888a8" dependencies = [ "anstream", "anstyle", @@ -255,22 +255,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] -name = "csbindgen" -version = "1.9.3" +name = "crossbeam-utils" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c26b9831049b947d154bba920e4124053def72447be6fb106a96f483874b482a" -dependencies = [ - "regex", - "syn", -] +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "dashmap" -version = "5.5.3" +version = "6.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" dependencies = [ "cfg-if", + "crossbeam-utils", "hashbrown 0.14.5", "lock_api", "once_cell", @@ -816,9 +813,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.96" +version = "1.0.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "beef09f85ae72cea1ef96ba6870c51e6382ebfa4f0e85b643459331f3daa5be0" +checksum = "d61789d7719defeb74ea5fe81f2fdfdbd28a803847077cecce2ff14e1472f6f1" dependencies = [ "unicode-ident", ] @@ -959,6 +956,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "thiserror", "url", "uuid", ] @@ -969,7 +967,6 @@ version = "0.5.0" dependencies = [ "anyhow", "cbindgen", - "csbindgen", "regorus", "serde_json", ] @@ -1117,9 +1114,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.104" +version = "2.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "7bc3fcb250e53458e712715cf74285c1f889686520d79294a9ef3bd7aa1fc619" dependencies = [ "proc-macro2", "quote", @@ -1150,6 +1147,26 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "thiserror" +version = "2.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b0949c3a6c842cbde3f1686d6eea5a010516deb7085f79db747562d4102f41e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc5b44b4ab9c2fdd0e0512e6bece8388e214c0749f5862b114cc5b7a25daf227" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tinystr" version = "0.8.1" diff --git a/bindings/ffi/Cargo.toml b/bindings/ffi/Cargo.toml index 742fe927..dc6f9338 100644 --- a/bindings/ffi/Cargo.toml +++ b/bindings/ffi/Cargo.toml @@ -32,4 +32,3 @@ custom_allocator = [] [build-dependencies] cbindgen = "0.28.0" -csbindgen = "=1.9.3" diff --git a/bindings/ffi/build.rs b/bindings/ffi/build.rs index ac20a596..ce30f474 100644 --- a/bindings/ffi/build.rs +++ b/bindings/ffi/build.rs @@ -1,5 +1,4 @@ extern crate cbindgen; -extern crate csbindgen; use std::env; @@ -21,12 +20,4 @@ fn main() { .generate() .expect("Unable to generate bindings") .write_to_file("regorus.ffi.hpp"); - - csbindgen::Builder::default() - .input_extern_file("src/lib.rs") - .csharp_dll_name("regorus_ffi") - .csharp_class_name("API") - .csharp_namespace("Regorus.Internal") - .generate_csharp_file("./RegorusFFI.g.cs") - .unwrap(); } diff --git a/bindings/ffi/src/allocator.rs b/bindings/ffi/src/allocator.rs new file mode 100644 index 00000000..b19cd82e --- /dev/null +++ b/bindings/ffi/src/allocator.rs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(feature = "custom_allocator")] +extern "C" { + fn regorus_aligned_alloc(alignment: usize, size: usize) -> *mut u8; + fn regorus_free(ptr: *mut u8); +} + +#[cfg(feature = "custom_allocator")] +mod allocator { + use std::alloc::{GlobalAlloc, Layout}; + + struct RegorusAllocator {} + + unsafe impl GlobalAlloc for RegorusAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let size = layout.size(); + let align = layout.align(); + + crate::allocator::regorus_aligned_alloc(align, size) + } + + unsafe fn dealloc(&self, ptr: *mut u8, _layout: Layout) { + crate::allocator::regorus_free(ptr) + } + } + + #[global_allocator] + static ALLOCATOR: RegorusAllocator = RegorusAllocator {}; +} diff --git a/bindings/ffi/src/common.rs b/bindings/ffi/src/common.rs new file mode 100644 index 00000000..e48b3e86 --- /dev/null +++ b/bindings/ffi/src/common.rs @@ -0,0 +1,229 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use anyhow::{anyhow, bail, Result}; +use std::ffi::{CStr, CString}; +use std::os::raw::{c_char, c_longlong}; + +/// Status of a call on `RegorusEngine`. +#[repr(C)] +pub enum RegorusStatus { + /// The operation was successful. + Ok, + + /// The operation was unsuccessful. + Error, + + /// Invalid data format provided. + InvalidDataFormat, + + /// Invalid entrypoint rule specified. + InvalidEntrypoint, + + /// Compilation failed. + CompilationFailed, + + /// Invalid argument provided. + InvalidArgument, + + /// Invalid module ID. + InvalidModuleId, + + /// Invalid policy content. + InvalidPolicy, +} + +/// Type of data contained in RegorusResult +#[repr(C)] +#[allow(unused)] +pub enum RegorusDataType { + /// No data / void + None, + /// String data (output field is valid) + String, + /// Boolean data (bool_value field is valid) + Boolean, + /// Integer data (int_value field is valid) + Integer, + /// Pointer data (pointer_value field is valid) + Pointer, +} + +/// Result of a call on `RegorusEngine`. +/// +/// Must be freed using `regorus_result_drop`. +#[repr(C)] +pub struct RegorusResult { + /// Status + pub(crate) status: RegorusStatus, + + /// Type of data contained in this result + pub(crate) data_type: RegorusDataType, + + /// String output produced by the call. + /// Valid when data_type is String. Owned by Rust. + pub(crate) output: *mut c_char, + + /// Boolean value. + /// Valid when data_type is Boolean. + pub(crate) bool_value: bool, + + /// Integer value. + /// Valid when data_type is Integer. + pub(crate) int_value: c_longlong, + + /// Pointer value. + /// Valid when data_type is Pointer. + pub(crate) pointer_value: *mut std::os::raw::c_void, + + /// Errors produced by the call. + /// Owned by Rust. + pub(crate) error_message: *mut c_char, +} + +impl RegorusResult { + /// Create a successful result with no data. + pub(crate) fn ok_void() -> Self { + Self { + status: RegorusStatus::Ok, + data_type: RegorusDataType::None, + output: std::ptr::null_mut(), + bool_value: false, + int_value: 0, + pointer_value: std::ptr::null_mut(), + error_message: std::ptr::null_mut(), + } + } + + /// Create a successful result with string output. + pub(crate) fn ok_string(output: String) -> Self { + Self { + status: RegorusStatus::Ok, + data_type: RegorusDataType::String, + output: to_c_str(output), + bool_value: false, + int_value: 0, + pointer_value: std::ptr::null_mut(), + error_message: std::ptr::null_mut(), + } + } + + /// Create a successful result with boolean value. + #[allow(unused)] + pub(crate) fn ok_bool(value: bool) -> Self { + Self { + status: RegorusStatus::Ok, + data_type: RegorusDataType::Boolean, + output: std::ptr::null_mut(), + bool_value: value, + int_value: 0, + pointer_value: std::ptr::null_mut(), + error_message: std::ptr::null_mut(), + } + } + + /// Create a successful result with integer value. + #[allow(unused)] + pub(crate) fn ok_int(value: i64) -> Self { + Self { + status: RegorusStatus::Ok, + data_type: RegorusDataType::Integer, + output: std::ptr::null_mut(), + bool_value: false, + int_value: value as c_longlong, + pointer_value: std::ptr::null_mut(), + error_message: std::ptr::null_mut(), + } + } + + /// Create a successful result with pointer value. + pub(crate) fn ok_pointer(pointer: *mut std::os::raw::c_void) -> Self { + Self { + status: RegorusStatus::Ok, + data_type: RegorusDataType::Pointer, + output: std::ptr::null_mut(), + bool_value: false, + int_value: 0, + pointer_value: pointer, + error_message: std::ptr::null_mut(), + } + } + + /// Create an error result with specific status. + pub(crate) fn err(status: RegorusStatus) -> Self { + Self { + status, + data_type: RegorusDataType::None, + output: std::ptr::null_mut(), + bool_value: false, + int_value: 0, + pointer_value: std::ptr::null_mut(), + error_message: std::ptr::null_mut(), + } + } + + /// Create an error result with status and message. + pub(crate) fn err_with_message(status: RegorusStatus, message: String) -> Self { + Self { + status, + data_type: RegorusDataType::None, + output: std::ptr::null_mut(), + bool_value: false, + int_value: 0, + pointer_value: std::ptr::null_mut(), + error_message: to_c_str(message), + } + } +} + +pub(crate) fn to_c_str(s: String) -> *mut c_char { + match CString::new(s) { + Ok(cs) => cs.into_raw(), + _ => to_c_str("binding error: failed to create c-style string".to_string()), + } +} + +pub(crate) fn from_c_str(s: *const c_char) -> Result { + if s.is_null() { + bail!("null pointer"); + } + unsafe { + CStr::from_ptr(s) + .to_str() + .map_err(|e| anyhow!("invalid utf8: {e}")) + .map(|s| s.to_string()) + } +} + +pub(crate) fn to_ref<'a, T>(t: *mut T) -> Result<&'a mut T> { + unsafe { t.as_mut().ok_or_else(|| anyhow!("null pointer")) } +} + +pub(crate) fn to_regorus_result(r: Result<()>) -> RegorusResult { + match r { + Ok(()) => RegorusResult::ok_void(), + Err(e) => RegorusResult::err_with_message(RegorusStatus::Error, format!("{e}")), + } +} + +pub(crate) fn to_regorus_string_result(r: Result) -> RegorusResult { + match r { + Ok(s) => RegorusResult::ok_string(s), + Err(e) => RegorusResult::err_with_message(RegorusStatus::Error, format!("{e}")), + } +} + +/// Drop a `RegorusResult`. +/// +/// `output` and `error_message` strings are not valid after drop. +#[no_mangle] +pub extern "C" fn regorus_result_drop(r: RegorusResult) { + unsafe { + if !r.error_message.is_null() { + let _ = CString::from_raw(r.error_message); + } + if !r.output.is_null() { + let _ = CString::from_raw(r.output); + } + } +} diff --git a/bindings/ffi/src/compile.rs b/bindings/ffi/src/compile.rs new file mode 100644 index 00000000..9d7f6720 --- /dev/null +++ b/bindings/ffi/src/compile.rs @@ -0,0 +1,208 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +use crate::common::{from_c_str, RegorusResult, RegorusStatus}; +use crate::compiled_policy::RegorusCompiledPolicy; +use regorus::{compile_policy_with_entrypoint, PolicyModule, Value}; + +#[cfg(feature = "azure_policy")] +use regorus::compile_policy_for_target; + +use std::os::raw::c_char; + +/// FFI wrapper for PolicyModule struct. +#[repr(C)] +pub struct RegorusPolicyModule { + pub id: *const c_char, + pub content: *const c_char, +} + +/// Compiles a policy from data and modules with a specific entry point rule. +/// +/// This is a convenience function that wraps [`regorus::compile_policy_with_entrypoint`]. +/// It sets up an Engine internally and calls the appropriate compilation method. +/// +/// # Parameters +/// * `data_json` - JSON string containing static data for policy evaluation +/// * `modules` - Array of policy modules to compile +/// * `modules_len` - Number of modules in the array +/// * `entry_point_rule` - The specific rule path to evaluate (e.g., "data.policy.allow") +/// +/// # Returns +/// Returns a RegorusResult containing a RegorusCompiledPolicy handle on success. +/// +/// # Safety +/// All string parameters must be valid null-terminated UTF-8 strings. +/// The modules array must contain exactly `modules_len` valid elements. +/// The caller must eventually call regorus_compiled_policy_drop on the returned handle. +#[no_mangle] +pub extern "C" fn regorus_compile_policy_with_entrypoint( + data_json: *const c_char, + modules: *const RegorusPolicyModule, + modules_len: usize, + entry_point_rule: *const c_char, +) -> RegorusResult { + let data_str = match from_c_str(data_json) { + Ok(s) => s, + Err(e) => { + return RegorusResult::err_with_message( + RegorusStatus::InvalidDataFormat, + format!("Invalid data JSON string: {e}"), + ) + } + }; + + let entry_rule = match from_c_str(entry_point_rule) { + Ok(s) => s, + Err(e) => { + return RegorusResult::err_with_message( + RegorusStatus::InvalidEntrypoint, + format!("Invalid entry point rule string: {e}"), + ) + } + }; + + // Parse data JSON + let data = match Value::from_json_str(&data_str) { + Ok(data) => data, + Err(e) => { + return RegorusResult::err_with_message( + RegorusStatus::InvalidDataFormat, + format!("Failed to parse data JSON: {e}"), + ) + } + }; + + // Convert C modules array to Rust Vec + let policy_modules = match convert_c_modules_to_rust(modules, modules_len) { + Ok(modules) => modules, + Err(status) => return RegorusResult::err(status), + }; + + // Call the convenience function + match compile_policy_with_entrypoint(data, &policy_modules, entry_rule.into()) { + Ok(compiled_policy) => { + let wrapped_policy = RegorusCompiledPolicy { compiled_policy }; + let boxed_policy = Box::new(wrapped_policy); + RegorusResult::ok_pointer(Box::into_raw(boxed_policy) as *mut std::os::raw::c_void) + } + Err(e) => RegorusResult::err_with_message( + RegorusStatus::CompilationFailed, + format!("Policy compilation failed: {e}"), + ), + } +} + +/// Compiles a target-aware policy from data and modules. +/// +/// This is a convenience function that wraps [`regorus::compile_policy_for_target`]. +/// It sets up an Engine internally and calls target-aware compilation. +/// +/// # Parameters +/// * `data_json` - JSON string containing static data for policy evaluation +/// * `modules` - Array of policy modules to compile +/// * `modules_len` - Number of modules in the array +/// +/// # Returns +/// Returns a RegorusResult containing a RegorusCompiledPolicy handle on success. +/// +/// # Note +/// This function is only available when the `azure_policy` feature is enabled. +/// At least one module must contain a `__target__` declaration. +/// +/// # Safety +/// All string parameters must be valid null-terminated UTF-8 strings. +/// The modules array must contain exactly `modules_len` valid elements. +/// The caller must eventually call regorus_compiled_policy_drop on the returned handle. +#[cfg(feature = "azure_policy")] +#[no_mangle] +pub extern "C" fn regorus_compile_policy_for_target( + data_json: *const c_char, + modules: *const RegorusPolicyModule, + modules_len: usize, +) -> RegorusResult { + let data_str = match from_c_str(data_json) { + Ok(s) => s, + Err(e) => { + return RegorusResult::err_with_message( + RegorusStatus::InvalidDataFormat, + format!("Invalid data JSON string: {e}"), + ) + } + }; + + // Parse data JSON + let data = match Value::from_json_str(&data_str) { + Ok(data) => data, + Err(e) => { + return RegorusResult::err_with_message( + RegorusStatus::InvalidDataFormat, + format!("Failed to parse data JSON: {e}"), + ) + } + }; + + // Convert C modules array to Rust Vec + let policy_modules = match convert_c_modules_to_rust(modules, modules_len) { + Ok(modules) => modules, + Err(status) => return RegorusResult::err(status), + }; + + // Call the convenience function + match compile_policy_for_target(data, &policy_modules) { + Ok(compiled_policy) => { + let wrapped_policy = RegorusCompiledPolicy { compiled_policy }; + let boxed_policy = Box::new(wrapped_policy); + RegorusResult::ok_pointer(Box::into_raw(boxed_policy) as *mut std::os::raw::c_void) + } + Err(e) => RegorusResult::err_with_message( + RegorusStatus::CompilationFailed, + format!("Target-aware policy compilation failed: {e}"), + ), + } +} + +/// Helper function to convert C module array to Rust Vec. +fn convert_c_modules_to_rust( + modules: *const RegorusPolicyModule, + modules_len: usize, +) -> Result, RegorusStatus> { + if modules.is_null() && modules_len > 0 { + return Err(RegorusStatus::InvalidArgument); + } + + let mut policy_modules = Vec::with_capacity(modules_len); + + for i in 0..modules_len { + unsafe { + let module = modules.add(i); + if module.is_null() { + return Err(RegorusStatus::InvalidArgument); + } + + let module_ref = &*module; + + let id = match from_c_str(module_ref.id) { + Ok(s) => s, + Err(e) => { + eprintln!("Invalid module ID at index {}: {}", i, e); + return Err(RegorusStatus::InvalidModuleId); + } + }; + + let content = match from_c_str(module_ref.content) { + Ok(s) => s, + Err(e) => { + eprintln!("Invalid module content at index {}: {}", i, e); + return Err(RegorusStatus::InvalidPolicy); + } + }; + + policy_modules.push(PolicyModule { + id: id.into(), + content: content.into(), + }); + } + } + + Ok(policy_modules) +} diff --git a/bindings/ffi/src/compiled_policy.rs b/bindings/ffi/src/compiled_policy.rs new file mode 100644 index 00000000..20aff136 --- /dev/null +++ b/bindings/ffi/src/compiled_policy.rs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::common::*; +use anyhow::Result; +use std::os::raw::c_char; + +/// Wrapper for `regorus::CompiledPolicy`. +#[derive(Clone)] +pub struct RegorusCompiledPolicy { + pub(crate) compiled_policy: regorus::CompiledPolicy, +} + +/// Drop a `RegorusCompiledPolicy`. +#[no_mangle] +pub extern "C" fn regorus_compiled_policy_drop(compiled_policy: *mut RegorusCompiledPolicy) { + if let Ok(cp) = to_ref(compiled_policy) { + unsafe { + let _ = Box::from_raw(std::ptr::from_mut(cp)); + } + } +} + +/// Evaluate the compiled policy with the given input. +/// +/// For target policies, evaluates the target's effect rule. +/// For regular policies, evaluates the originally compiled rule. +/// +/// * `input`: JSON encoded input data (resource) to validate against the policy. +#[no_mangle] +pub extern "C" fn regorus_compiled_policy_eval_with_input( + compiled_policy: *mut RegorusCompiledPolicy, + input: *const c_char, +) -> RegorusResult { + let output = || -> Result { + let input_value = regorus::Value::from_json_str(&from_c_str(input)?)?; + let result = to_ref(compiled_policy)? + .compiled_policy + .eval_with_input(input_value)?; + result.to_json_str() + }(); + + match output { + Ok(out) => RegorusResult::ok_string(out), + Err(e) => to_regorus_result(Err(e)), + } +} + +/// Get information about the compiled policy including metadata about modules, +/// target configuration, and resource types. +/// +/// Returns a JSON-encoded `PolicyInfo` struct containing comprehensive +/// information about the compiled policy such as module IDs, target name, +/// applicable resource types, entry point rule, and parameters. +#[no_mangle] +pub extern "C" fn regorus_compiled_policy_get_policy_info( + compiled_policy: *mut RegorusCompiledPolicy, +) -> RegorusResult { + let output = || -> Result { + let info = to_ref(compiled_policy)?.compiled_policy.get_policy_info()?; + serde_json::to_string(&info) + .map_err(|e| anyhow::anyhow!("Failed to serialize policy info: {}", e)) + }(); + + match output { + Ok(out) => RegorusResult::ok_string(out), + Err(e) => to_regorus_result(Err(e)), + } +} diff --git a/bindings/ffi/src/effect_registry.rs b/bindings/ffi/src/effect_registry.rs new file mode 100644 index 00000000..cdd348ae --- /dev/null +++ b/bindings/ffi/src/effect_registry.rs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Effect schema registry functions for FFI. +//! +//! These functions provide access to regorus's effect schema registry functionality, +//! enabling registration and management of Azure Policy effect schemas. + +#![cfg(feature = "azure_policy")] +use crate::common::{from_c_str, RegorusResult, RegorusStatus}; +use regorus::{registry::schemas, Schema}; + +use std::os::raw::c_char; + +/// Register an effect schema from JSON with a given name. +/// +/// # Parameters +/// * `name` - Name to register the schema under +/// * `schema_json` - JSON string representing the schema +/// +/// # Returns +/// Returns a RegorusResult with success/error status. +/// +/// # Safety +/// All string parameters must be valid null-terminated UTF-8 strings. +#[cfg(feature = "azure_policy")] +#[no_mangle] +pub extern "C" fn regorus_effect_schema_register( + name: *const c_char, + schema_json: *const c_char, +) -> RegorusResult { + let schema_name = match from_c_str(name) { + Ok(s) => s, + Err(e) => { + return RegorusResult::err_with_message( + RegorusStatus::InvalidArgument, + format!("Invalid effect schema name string: {e}"), + ) + } + }; + + let schema_str = match from_c_str(schema_json) { + Ok(s) => s, + Err(e) => { + return RegorusResult::err_with_message( + RegorusStatus::InvalidDataFormat, + format!("Invalid effect schema JSON string: {e}"), + ) + } + }; + + // Parse schema from JSON + let schema = match Schema::from_json_str(&schema_str) { + Ok(schema) => schema, + Err(e) => { + return RegorusResult::err_with_message( + RegorusStatus::InvalidDataFormat, + format!("Failed to parse effect schema JSON: {e}"), + ) + } + }; + + // Register the schema + match schemas::effect::register(schema_name, schema.into()) { + Ok(()) => RegorusResult::ok_pointer(std::ptr::null_mut()), + Err(e) => RegorusResult::err_with_message( + RegorusStatus::Error, + format!("Failed to register effect schema: {e}"), + ), + } +} + +/// Check if an effect schema with the given name exists. +/// +/// # Parameters +/// * `name` - Name of the schema to check +/// +/// # Returns +/// Returns a RegorusResult with "true" or "false" string output. +/// +/// # Safety +/// The name parameter must be a valid null-terminated UTF-8 string. +#[cfg(feature = "azure_policy")] +#[no_mangle] +pub extern "C" fn regorus_effect_schema_contains(name: *const c_char) -> RegorusResult { + let schema_name = match from_c_str(name) { + Ok(s) => s, + Err(e) => { + return RegorusResult::err_with_message( + RegorusStatus::InvalidArgument, + format!("Invalid effect schema name string: {e}"), + ) + } + }; + + let contains = schemas::effect::contains(&schema_name); + RegorusResult::ok_bool(contains) +} + +/// Get the number of registered effect schemas. +/// +/// # Returns +/// Returns a RegorusResult with the count as a string. +#[cfg(feature = "azure_policy")] +#[no_mangle] +pub extern "C" fn regorus_effect_schema_len() -> RegorusResult { + let count = schemas::effect::len(); + RegorusResult::ok_int(count as i64) +} + +/// Check if the effect schema registry is empty. +/// +/// # Returns +/// Returns a RegorusResult with "true" or "false" string output. +#[cfg(feature = "azure_policy")] +#[no_mangle] +pub extern "C" fn regorus_effect_schema_is_empty() -> RegorusResult { + let is_empty = schemas::effect::is_empty(); + RegorusResult::ok_bool(is_empty) +} + +/// List all registered effect schema names as a JSON array. +/// +/// # Returns +/// Returns a RegorusResult with a JSON array of schema names. +#[cfg(feature = "azure_policy")] +#[no_mangle] +pub extern "C" fn regorus_effect_schema_list_names() -> RegorusResult { + let names = schemas::effect::list_names(); + match serde_json::to_string(&names) { + Ok(json_str) => RegorusResult::ok_string(json_str), + Err(e) => RegorusResult::err_with_message( + RegorusStatus::Error, + format!("Failed to serialize effect schema names to JSON: {e}"), + ), + } +} + +/// Remove an effect schema by name. +/// +/// # Parameters +/// * `name` - Name of the schema to remove +/// +/// # Returns +/// Returns a RegorusResult with "true" if removed, "false" if not found. +/// +/// # Safety +/// The name parameter must be a valid null-terminated UTF-8 string. +#[cfg(feature = "azure_policy")] +#[no_mangle] +pub extern "C" fn regorus_effect_schema_remove(name: *const c_char) -> RegorusResult { + let schema_name = match from_c_str(name) { + Ok(s) => s, + Err(e) => { + return RegorusResult::err_with_message( + RegorusStatus::InvalidArgument, + format!("Invalid effect schema name string: {e}"), + ) + } + }; + + let removed = schemas::effect::remove(&schema_name).is_some(); + RegorusResult::ok_bool(removed) +} + +/// Clear all effect schemas from the registry. +/// +/// # Returns +/// Returns a RegorusResult with success status. +#[cfg(feature = "azure_policy")] +#[no_mangle] +pub extern "C" fn regorus_effect_schema_clear() -> RegorusResult { + schemas::effect::clear(); + RegorusResult::ok_pointer(std::ptr::null_mut()) +} diff --git a/bindings/ffi/src/engine.rs b/bindings/ffi/src/engine.rs new file mode 100644 index 00000000..864e75f9 --- /dev/null +++ b/bindings/ffi/src/engine.rs @@ -0,0 +1,454 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::common::{ + from_c_str, to_ref, to_regorus_result, to_regorus_string_result, RegorusResult, RegorusStatus, +}; +use crate::compiled_policy::RegorusCompiledPolicy; +use anyhow::Result; +use std::os::raw::c_char; + +/// Wrapper for `regorus::Engine`. +#[derive(Clone)] +pub struct RegorusEngine { + engine: ::regorus::Engine, +} + +#[no_mangle] +/// Construct a new Engine +/// +/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html +pub extern "C" fn regorus_engine_new() -> *mut RegorusEngine { + let mut engine = ::regorus::Engine::new(); + + // For more OPA compatibility out of the box, we ask builtins to return undefined + // instead of raising errors in certain failure scenarios. + engine.set_strict_builtin_errors(false); + + Box::into_raw(Box::new(RegorusEngine { engine })) +} + +/// Clone a [`RegorusEngine`] +/// +/// To avoid having to parse same policy again, the engine can be cloned +/// after policies and data have been added. +/// +#[no_mangle] +pub extern "C" fn regorus_engine_clone(engine: *mut RegorusEngine) -> *mut RegorusEngine { + match to_ref(engine) { + Ok(e) => Box::into_raw(Box::new(e.clone())), + _ => std::ptr::null_mut(), + } +} + +#[no_mangle] +pub extern "C" fn regorus_engine_drop(engine: *mut RegorusEngine) { + if let Ok(e) = to_ref(engine) { + unsafe { + let _ = Box::from_raw(std::ptr::from_mut(e)); + } + } +} + +/// Add a policy +/// +/// The policy is parsed into AST. +/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.add_policy +/// +/// * `path`: A filename to be associated with the policy. +/// * `rego`: Rego policy. +#[no_mangle] +pub extern "C" fn regorus_engine_add_policy( + engine: *mut RegorusEngine, + path: *const c_char, + rego: *const c_char, +) -> RegorusResult { + to_regorus_string_result(|| -> Result { + to_ref(engine)? + .engine + .add_policy(from_c_str(path)?, from_c_str(rego)?) + }()) +} + +#[cfg(feature = "std")] +#[no_mangle] +pub extern "C" fn regorus_engine_add_policy_from_file( + engine: *mut RegorusEngine, + path: *const c_char, +) -> RegorusResult { + to_regorus_string_result(|| -> Result { + to_ref(engine)? + .engine + .add_policy_from_file(from_c_str(path)?) + }()) +} + +/// Add policy data. +/// +/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.add_data +/// * `data`: JSON encoded value to be used as policy data. +#[no_mangle] +pub extern "C" fn regorus_engine_add_data_json( + engine: *mut RegorusEngine, + data: *const c_char, +) -> RegorusResult { + to_regorus_result(|| -> Result<()> { + to_ref(engine)? + .engine + .add_data(regorus::Value::from_json_str(&from_c_str(data)?)?) + }()) +} + +/// Get list of loaded Rego packages as JSON. +/// +/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.get_packages +#[no_mangle] +pub extern "C" fn regorus_engine_get_packages(engine: *mut RegorusEngine) -> RegorusResult { + to_regorus_string_result(|| -> Result { + serde_json::to_string_pretty(&to_ref(engine)?.engine.get_packages()?) + .map_err(anyhow::Error::msg) + }()) +} + +/// Get list of policies as JSON. +/// +/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.get_policies +#[no_mangle] +pub extern "C" fn regorus_engine_get_policies(engine: *mut RegorusEngine) -> RegorusResult { + to_regorus_string_result(|| -> Result { + to_ref(engine)?.engine.get_policies_as_json() + }()) +} + +#[cfg(feature = "std")] +#[no_mangle] +pub extern "C" fn regorus_engine_add_data_from_json_file( + engine: *mut RegorusEngine, + path: *const c_char, +) -> RegorusResult { + to_regorus_result(|| -> Result<()> { + to_ref(engine)? + .engine + .add_data(regorus::Value::from_json_file(from_c_str(path)?)?) + }()) +} + +/// Clear policy data. +/// +/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.clear_data +#[no_mangle] +pub extern "C" fn regorus_engine_clear_data(engine: *mut RegorusEngine) -> RegorusResult { + to_regorus_result(|| -> Result<()> { + to_ref(engine)?.engine.clear_data(); + Ok(()) + }()) +} + +/// Set input. +/// +/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.set_input +/// * `input`: JSON encoded value to be used as input to query. +#[no_mangle] +pub extern "C" fn regorus_engine_set_input_json( + engine: *mut RegorusEngine, + input: *const c_char, +) -> RegorusResult { + to_regorus_result(|| -> Result<()> { + to_ref(engine)? + .engine + .set_input(regorus::Value::from_json_str(&from_c_str(input)?)?); + Ok(()) + }()) +} + +#[cfg(feature = "std")] +#[no_mangle] +pub extern "C" fn regorus_engine_set_input_from_json_file( + engine: *mut RegorusEngine, + path: *const c_char, +) -> RegorusResult { + to_regorus_result(|| -> Result<()> { + to_ref(engine)? + .engine + .set_input(regorus::Value::from_json_file(from_c_str(path)?)?); + Ok(()) + }()) +} + +/// Evaluate query. +/// +/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.eval_query +/// * `query`: Rego expression to be evaluate. +#[no_mangle] +pub extern "C" fn regorus_engine_eval_query( + engine: *mut RegorusEngine, + query: *const c_char, +) -> RegorusResult { + let output = || -> Result { + let results = to_ref(engine)? + .engine + .eval_query(from_c_str(query)?, false)?; + Ok(serde_json::to_string_pretty(&results)?) + }(); + match output { + Ok(out) => RegorusResult::ok_string(out), + Err(e) => to_regorus_result(Err(e)), + } +} + +/// Evaluate specified rule. +/// +/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.eval_rule +/// * `rule`: Path to the rule. +#[no_mangle] +pub extern "C" fn regorus_engine_eval_rule( + engine: *mut RegorusEngine, + rule: *const c_char, +) -> RegorusResult { + let output = || -> Result { + to_ref(engine)? + .engine + .eval_rule(from_c_str(rule)?)? + .to_json_str() + }(); + match output { + Ok(out) => RegorusResult::ok_string(out), + Err(e) => to_regorus_result(Err(e)), + } +} + +/// Enable/disable coverage. +/// +/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.set_enable_coverage +/// * `enable`: Whether to enable or disable coverage. +#[no_mangle] +#[cfg(feature = "coverage")] +pub extern "C" fn regorus_engine_set_enable_coverage( + engine: *mut RegorusEngine, + enable: bool, +) -> RegorusResult { + to_regorus_result(|| -> Result<()> { + to_ref(engine)?.engine.set_enable_coverage(enable); + Ok(()) + }()) +} + +/// Get coverage report. +/// +/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.get_coverage_report +#[no_mangle] +#[cfg(feature = "coverage")] +pub extern "C" fn regorus_engine_get_coverage_report(engine: *mut RegorusEngine) -> RegorusResult { + let output = || -> Result { + Ok(serde_json::to_string_pretty( + &to_ref(engine)?.engine.get_coverage_report()?, + )?) + }(); + match output { + Ok(out) => RegorusResult::ok_string(out), + Err(e) => to_regorus_result(Err(e)), + } +} + +/// Enable/disable strict builtin errors. +/// +/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.set_strict_builtin_errors +/// * `strict`: Whether to raise errors or return undefined on certain scenarios. +#[no_mangle] +pub extern "C" fn regorus_engine_set_strict_builtin_errors( + engine: *mut RegorusEngine, + strict: bool, +) -> RegorusResult { + to_regorus_result(|| -> Result<()> { + to_ref(engine)?.engine.set_strict_builtin_errors(strict); + Ok(()) + }()) +} + +/// Get pretty printed coverage report. +/// +/// See https://docs.rs/regorus/latest/regorus/coverage/struct.Report.html#method.to_string_pretty +#[no_mangle] +#[cfg(feature = "coverage")] +pub extern "C" fn regorus_engine_get_coverage_report_pretty( + engine: *mut RegorusEngine, +) -> RegorusResult { + let output = || -> Result { + to_ref(engine)? + .engine + .get_coverage_report()? + .to_string_pretty() + }(); + match output { + Ok(out) => RegorusResult::ok_string(out), + Err(e) => to_regorus_result(Err(e)), + } +} + +/// Clear coverage data. +/// +/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.clear_coverage_data +#[no_mangle] +#[cfg(feature = "coverage")] +pub extern "C" fn regorus_engine_clear_coverage_data(engine: *mut RegorusEngine) -> RegorusResult { + to_regorus_result(|| -> Result<()> { + to_ref(engine)?.engine.clear_coverage_data(); + Ok(()) + }()) +} + +/// Whether to gather output of print statements. +/// +/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.set_gather_prints +/// * `enable`: Whether to enable or disable gathering print statements. +#[no_mangle] +pub extern "C" fn regorus_engine_set_gather_prints( + engine: *mut RegorusEngine, + enable: bool, +) -> RegorusResult { + to_regorus_result(|| -> Result<()> { + to_ref(engine)?.engine.set_gather_prints(enable); + Ok(()) + }()) +} + +/// Take all the gathered print statements. +/// +/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.take_prints +#[no_mangle] +pub extern "C" fn regorus_engine_take_prints(engine: *mut RegorusEngine) -> RegorusResult { + let output = || -> Result { + Ok(serde_json::to_string_pretty( + &to_ref(engine)?.engine.take_prints()?, + )?) + }(); + match output { + Ok(out) => RegorusResult::ok_string(out), + Err(e) => to_regorus_result(Err(e)), + } +} + +/// Get AST of policies. +/// +/// See https://docs.rs/regorus/latest/regorus/coverage/struct.Engine.html#method.get_ast_as_json +#[no_mangle] +#[cfg(feature = "ast")] +pub extern "C" fn regorus_engine_get_ast_as_json(engine: *mut RegorusEngine) -> RegorusResult { + let output = || -> Result { to_ref(engine)?.engine.get_ast_as_json() }(); + match output { + Ok(out) => RegorusResult::ok_string(out), + Err(e) => to_regorus_result(Err(e)), + } +} + +/// Gets the package names defined in each policy added to the engine. +/// +/// See https://docs.rs/regorus/latest/regorus/coverage/struct.Engine.html#method.get_policy_package_names +#[no_mangle] +#[cfg(feature = "azure_policy")] +pub extern "C" fn regorus_engine_get_policy_package_names( + engine: *mut RegorusEngine, +) -> RegorusResult { + let output = || -> Result { + serde_json::to_string_pretty(&to_ref(engine)?.engine.get_policy_package_names()?) + .map_err(anyhow::Error::msg) + }(); + match output { + Ok(out) => RegorusResult::ok_string(out), + Err(e) => to_regorus_result(Err(e)), + } +} + +/// Gets the parameters defined in each policy added to the engine. +/// +/// See https://docs.rs/regorus/latest/regorus/coverage/struct.Engine.html#method.get_policy_parameters +#[no_mangle] +#[cfg(feature = "azure_policy")] +pub extern "C" fn regorus_engine_get_policy_parameters( + engine: *mut RegorusEngine, +) -> RegorusResult { + let output = || -> Result { + serde_json::to_string_pretty(&to_ref(engine)?.engine.get_policy_parameters()?) + .map_err(anyhow::Error::msg) + }(); + match output { + Ok(out) => RegorusResult::ok_string(out), + Err(e) => to_regorus_result(Err(e)), + } +} + +/// Enable/disable rego v1. +/// +/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.set_rego_v0 +#[no_mangle] +pub extern "C" fn regorus_engine_set_rego_v0( + engine: *mut RegorusEngine, + enable: bool, +) -> RegorusResult { + let output = || -> Result<()> { + to_ref(engine)?.engine.set_rego_v0(enable); + Ok(()) + }(); + match output { + Ok(()) => RegorusResult::ok_void(), + Err(e) => to_regorus_result(Err(e)), + } +} + +/// Compile a target-aware policy from the current engine state. +/// +/// This method creates a compiled policy that can work with Azure Policy targets, +/// enabling resource type inference and target-specific evaluation. +/// +/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.compile_for_target +#[no_mangle] +#[cfg(feature = "azure_policy")] +pub extern "C" fn regorus_engine_compile_for_target(engine: *mut RegorusEngine) -> RegorusResult { + match to_ref(engine) { + Ok(e) => match e.engine.compile_for_target() { + Ok(compiled_policy) => { + let wrapped_policy = RegorusCompiledPolicy { compiled_policy }; + let boxed_policy = Box::new(wrapped_policy); + RegorusResult::ok_pointer(Box::into_raw(boxed_policy) as *mut std::os::raw::c_void) + } + Err(e) => RegorusResult::err_with_message( + RegorusStatus::CompilationFailed, + format!("Failed to compile for target: {e}"), + ), + }, + Err(e) => RegorusResult::err_with_message( + RegorusStatus::InvalidArgument, + format!("Failed to get engine reference: {e}"), + ), + } +} + +/// Compile a policy with a specific entry point rule. +/// +/// This method creates a compiled policy that evaluates a specific rule as the entry point. +/// +/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.compile_with_entrypoint +/// * `rule`: The specific rule path to evaluate (e.g., "data.policy.allow") +#[no_mangle] +pub extern "C" fn regorus_engine_compile_with_entrypoint( + engine: *mut RegorusEngine, + rule: *const c_char, +) -> RegorusResult { + let result = || -> Result { + let rule_str = from_c_str(rule)?; + let rule_rc: regorus::Rc = rule_str.into(); + let compiled_policy = to_ref(engine)?.engine.compile_with_entrypoint(&rule_rc)?; + Ok(RegorusCompiledPolicy { compiled_policy }) + }(); + + match result { + Ok(wrapped_policy) => { + let boxed_policy = Box::new(wrapped_policy); + RegorusResult::ok_pointer(Box::into_raw(boxed_policy) as *mut std::os::raw::c_void) + } + Err(e) => RegorusResult::err_with_message( + RegorusStatus::CompilationFailed, + format!("Failed to compile with entrypoint: {e}"), + ), + } +} diff --git a/bindings/ffi/src/lib.rs b/bindings/ffi/src/lib.rs index 869aee2d..62273926 100644 --- a/bindings/ffi/src/lib.rs +++ b/bindings/ffi/src/lib.rs @@ -1,553 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use anyhow::{anyhow, bail, Result}; -use std::ffi::{CStr, CString}; -use std::os::raw::c_char; - -/// Status of a call on `RegorusEngine`. -#[repr(C)] -pub enum RegorusStatus { - /// The operation was successful. - RegorusStatusOk, - - /// The operation was unsuccessful. - RegorusStatusError, -} - -/// Result of a call on `RegorusEngine`. -/// -/// Must be freed using `regorus_result_drop`. -#[repr(C)] -pub struct RegorusResult { - /// Status - status: RegorusStatus, - - /// Output produced by the call. - /// Owned by Rust. - output: *mut c_char, - - /// Errors produced by the call. - /// Owned by Rust. - error_message: *mut c_char, -} - -fn to_c_str(s: String) -> *mut c_char { - match CString::new(s) { - Ok(cs) => cs.into_raw(), - _ => to_c_str("binding error: failed to create c-style string".to_string()), - } -} - -fn from_c_str(name: &str, s: *const c_char) -> Result { - if s.is_null() { - bail!("null pointer"); - } - unsafe { - CStr::from_ptr(s) - .to_str() - .map_err(|e| anyhow!("`{name}`: invalid utf8.\n{e}")) - .map(|s| s.to_string()) - } -} - -fn to_ref<'a, T>(t: *mut T) -> Result<&'a mut T> { - unsafe { t.as_mut().ok_or_else(|| anyhow!("null pointer")) } -} - -fn to_regorus_result(r: Result<()>) -> RegorusResult { - match r { - Ok(()) => RegorusResult { - status: RegorusStatus::RegorusStatusOk, - output: std::ptr::null_mut(), - error_message: std::ptr::null_mut(), - }, - Err(e) => RegorusResult { - status: RegorusStatus::RegorusStatusError, - output: std::ptr::null_mut(), - error_message: to_c_str(format!("{e}")), - }, - } -} - -fn to_regorus_string_result(r: Result) -> RegorusResult { - match r { - Ok(s) => RegorusResult { - status: RegorusStatus::RegorusStatusOk, - output: to_c_str(s), - error_message: std::ptr::null_mut(), - }, - Err(e) => RegorusResult { - status: RegorusStatus::RegorusStatusError, - output: std::ptr::null_mut(), - error_message: to_c_str(format!("{e}")), - }, - } -} - -/// Wrapper for `regorus::Engine`. -#[derive(Clone)] -pub struct RegorusEngine { - engine: ::regorus::Engine, -} - -/// Drop a `RegorusResult`. -/// -/// `output` and `error_message` strings are not valid after drop. -#[no_mangle] -pub extern "C" fn regorus_result_drop(r: RegorusResult) { - unsafe { - if !r.error_message.is_null() { - let _ = CString::from_raw(r.error_message); - } - if !r.output.is_null() { - let _ = CString::from_raw(r.output); - } - } -} - -#[no_mangle] -/// Construct a new Engine -/// -/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html -pub extern "C" fn regorus_engine_new() -> *mut RegorusEngine { - let mut engine = ::regorus::Engine::new(); - - // For more OPA compatibility out of the box, we ask builtins to return undefined - // instead of raising errors in certain failure scenarios. - engine.set_strict_builtin_errors(false); - - Box::into_raw(Box::new(RegorusEngine { engine })) -} - -/// Clone a [`RegorusEngine`] -/// -/// To avoid having to parse same policy again, the engine can be cloned -/// after policies and data have been added. -/// -#[no_mangle] -pub extern "C" fn regorus_engine_clone(engine: *mut RegorusEngine) -> *mut RegorusEngine { - match to_ref(engine) { - Ok(e) => Box::into_raw(Box::new(e.clone())), - _ => std::ptr::null_mut(), - } -} - -#[no_mangle] -pub extern "C" fn regorus_engine_drop(engine: *mut RegorusEngine) { - if let Ok(e) = to_ref(engine) { - unsafe { - let _ = Box::from_raw(std::ptr::from_mut(e)); - } - } -} - -/// Add a policy -/// -/// The policy is parsed into AST. -/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.add_policy -/// -/// * `path`: A filename to be associated with the policy. -/// * `rego`: Rego policy. -#[no_mangle] -pub extern "C" fn regorus_engine_add_policy( - engine: *mut RegorusEngine, - path: *const c_char, - rego: *const c_char, -) -> RegorusResult { - to_regorus_string_result(|| -> Result { - to_ref(engine)? - .engine - .add_policy(from_c_str("path", path)?, from_c_str("rego", rego)?) - }()) -} - -#[cfg(feature = "std")] -#[no_mangle] -pub extern "C" fn regorus_engine_add_policy_from_file( - engine: *mut RegorusEngine, - path: *const c_char, -) -> RegorusResult { - to_regorus_string_result(|| -> Result { - to_ref(engine)? - .engine - .add_policy_from_file(from_c_str("path", path)?) - }()) -} - -/// Add policy data. -/// -/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.add_data -/// * `data`: JSON encoded value to be used as policy data. -#[no_mangle] -pub extern "C" fn regorus_engine_add_data_json( - engine: *mut RegorusEngine, - data: *const c_char, -) -> RegorusResult { - to_regorus_result(|| -> Result<()> { - to_ref(engine)? - .engine - .add_data(regorus::Value::from_json_str(&from_c_str("data", data)?)?) - }()) -} - -/// Get list of loaded Rego packages as JSON. -/// -/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.get_packages -#[no_mangle] -pub extern "C" fn regorus_engine_get_packages(engine: *mut RegorusEngine) -> RegorusResult { - to_regorus_string_result(|| -> Result { - serde_json::to_string_pretty(&to_ref(engine)?.engine.get_packages()?) - .map_err(anyhow::Error::msg) - }()) -} - -/// Get list of policies as JSON. -/// -/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.get_policies -#[no_mangle] -pub extern "C" fn regorus_engine_get_policies(engine: *mut RegorusEngine) -> RegorusResult { - to_regorus_string_result(|| -> Result { - to_ref(engine)?.engine.get_policies_as_json() - }()) -} - -#[cfg(feature = "std")] -#[no_mangle] -pub extern "C" fn regorus_engine_add_data_from_json_file( - engine: *mut RegorusEngine, - path: *const c_char, -) -> RegorusResult { - to_regorus_result(|| -> Result<()> { - to_ref(engine)? - .engine - .add_data(regorus::Value::from_json_file(from_c_str("path", path)?)?) - }()) -} - -/// Clear policy data. -/// -/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.clear_data -#[no_mangle] -pub extern "C" fn regorus_engine_clear_data(engine: *mut RegorusEngine) -> RegorusResult { - to_regorus_result(|| -> Result<()> { - to_ref(engine)?.engine.clear_data(); - Ok(()) - }()) -} - -/// Set input. -/// -/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.set_input -/// * `input`: JSON encoded value to be used as input to query. -#[no_mangle] -pub extern "C" fn regorus_engine_set_input_json( - engine: *mut RegorusEngine, - input: *const c_char, -) -> RegorusResult { - to_regorus_result(|| -> Result<()> { - to_ref(engine)? - .engine - .set_input(regorus::Value::from_json_str(&from_c_str("input", input)?)?); - Ok(()) - }()) -} - -#[cfg(feature = "std")] -#[no_mangle] -pub extern "C" fn regorus_engine_set_input_from_json_file( - engine: *mut RegorusEngine, - path: *const c_char, -) -> RegorusResult { - to_regorus_result(|| -> Result<()> { - to_ref(engine)? - .engine - .set_input(regorus::Value::from_json_file(from_c_str("path", path)?)?); - Ok(()) - }()) -} - -/// Evaluate query. -/// -/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.eval_query -/// * `query`: Rego expression to be evaluate. -#[no_mangle] -pub extern "C" fn regorus_engine_eval_query( - engine: *mut RegorusEngine, - query: *const c_char, -) -> RegorusResult { - let output = || -> Result { - let results = to_ref(engine)? - .engine - .eval_query(from_c_str("query", query)?, false)?; - Ok(serde_json::to_string_pretty(&results)?) - }(); - match output { - Ok(out) => RegorusResult { - status: RegorusStatus::RegorusStatusOk, - output: to_c_str(out), - error_message: std::ptr::null_mut(), - }, - Err(e) => to_regorus_result(Err(e)), - } -} - -/// Evaluate specified rule. -/// -/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.eval_rule -/// * `rule`: Path to the rule. -#[no_mangle] -pub extern "C" fn regorus_engine_eval_rule( - engine: *mut RegorusEngine, - rule: *const c_char, -) -> RegorusResult { - let output = || -> Result { - to_ref(engine)? - .engine - .eval_rule(from_c_str("rule", rule)?)? - .to_json_str() - }(); - match output { - Ok(out) => RegorusResult { - status: RegorusStatus::RegorusStatusOk, - output: to_c_str(out), - error_message: std::ptr::null_mut(), - }, - Err(e) => to_regorus_result(Err(e)), - } -} - -/// Enable/disable coverage. -/// -/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.set_enable_coverage -/// * `enable`: Whether to enable or disable coverage. -#[no_mangle] -#[cfg(feature = "coverage")] -pub extern "C" fn regorus_engine_set_enable_coverage( - engine: *mut RegorusEngine, - enable: bool, -) -> RegorusResult { - to_regorus_result(|| -> Result<()> { - to_ref(engine)?.engine.set_enable_coverage(enable); - Ok(()) - }()) -} - -/// Get coverage report. -/// -/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.get_coverage_report -#[no_mangle] -#[cfg(feature = "coverage")] -pub extern "C" fn regorus_engine_get_coverage_report(engine: *mut RegorusEngine) -> RegorusResult { - let output = || -> Result { - Ok(serde_json::to_string_pretty( - &to_ref(engine)?.engine.get_coverage_report()?, - )?) - }(); - match output { - Ok(out) => RegorusResult { - status: RegorusStatus::RegorusStatusOk, - output: to_c_str(out), - error_message: std::ptr::null_mut(), - }, - Err(e) => to_regorus_result(Err(e)), - } -} - -/// Enable/disable strict builtin errors. -/// -/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.set_strict_builtin_errors -/// * `strict`: Whether to raise errors or return undefined on certain scenarios. -#[no_mangle] -pub extern "C" fn regorus_engine_set_strict_builtin_errors( - engine: *mut RegorusEngine, - strict: bool, -) -> RegorusResult { - to_regorus_result(|| -> Result<()> { - to_ref(engine)?.engine.set_strict_builtin_errors(strict); - Ok(()) - }()) -} - -/// Get pretty printed coverage report. -/// -/// See https://docs.rs/regorus/latest/regorus/coverage/struct.Report.html#method.to_string_pretty -#[no_mangle] -#[cfg(feature = "coverage")] -pub extern "C" fn regorus_engine_get_coverage_report_pretty( - engine: *mut RegorusEngine, -) -> RegorusResult { - let output = || -> Result { - to_ref(engine)? - .engine - .get_coverage_report()? - .to_string_pretty() - }(); - match output { - Ok(out) => RegorusResult { - status: RegorusStatus::RegorusStatusOk, - output: to_c_str(out), - error_message: std::ptr::null_mut(), - }, - Err(e) => to_regorus_result(Err(e)), - } -} - -/// Clear coverage data. -/// -/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.clear_coverage_data -#[no_mangle] -#[cfg(feature = "coverage")] -pub extern "C" fn regorus_engine_clear_coverage_data(engine: *mut RegorusEngine) -> RegorusResult { - to_regorus_result(|| -> Result<()> { - to_ref(engine)?.engine.clear_coverage_data(); - Ok(()) - }()) -} - -/// Whether to gather output of print statements. -/// -/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.set_gather_prints -/// * `enable`: Whether to enable or disable gathering print statements. -#[no_mangle] -pub extern "C" fn regorus_engine_set_gather_prints( - engine: *mut RegorusEngine, - enable: bool, -) -> RegorusResult { - to_regorus_result(|| -> Result<()> { - to_ref(engine)?.engine.set_gather_prints(enable); - Ok(()) - }()) -} - -/// Take all the gathered print statements. -/// -/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.take_prints -#[no_mangle] -pub extern "C" fn regorus_engine_take_prints(engine: *mut RegorusEngine) -> RegorusResult { - let output = || -> Result { - Ok(serde_json::to_string_pretty( - &to_ref(engine)?.engine.take_prints()?, - )?) - }(); - match output { - Ok(out) => RegorusResult { - status: RegorusStatus::RegorusStatusOk, - output: to_c_str(out), - error_message: std::ptr::null_mut(), - }, - Err(e) => to_regorus_result(Err(e)), - } -} - -/// Get AST of policies. -/// -/// See https://docs.rs/regorus/latest/regorus/coverage/struct.Engine.html#method.get_ast_as_json -#[no_mangle] -#[cfg(feature = "ast")] -pub extern "C" fn regorus_engine_get_ast_as_json(engine: *mut RegorusEngine) -> RegorusResult { - let output = || -> Result { to_ref(engine)?.engine.get_ast_as_json() }(); - match output { - Ok(out) => RegorusResult { - status: RegorusStatus::RegorusStatusOk, - output: to_c_str(out), - error_message: std::ptr::null_mut(), - }, - Err(e) => to_regorus_result(Err(e)), - } -} - -/// Gets the package names defined in each policy added to the engine. -/// -/// See https://docs.rs/regorus/latest/regorus/coverage/struct.Engine.html#method.get_policy_package_names -#[no_mangle] -#[cfg(feature = "azure_policy")] -pub extern "C" fn regorus_engine_get_policy_package_names( - engine: *mut RegorusEngine, -) -> RegorusResult { - let output = || -> Result { - serde_json::to_string_pretty(&to_ref(engine)?.engine.get_policy_package_names()?) - .map_err(anyhow::Error::msg) - }(); - match output { - Ok(out) => RegorusResult { - status: RegorusStatus::RegorusStatusOk, - output: to_c_str(out), - error_message: std::ptr::null_mut(), - }, - Err(e) => to_regorus_result(Err(e)), - } -} - -/// Gets the parameters defined in each policy added to the engine. -/// -/// See https://docs.rs/regorus/latest/regorus/coverage/struct.Engine.html#method.get_policy_parameters -#[no_mangle] -#[cfg(feature = "azure_policy")] -pub extern "C" fn regorus_engine_get_policy_parameters( - engine: *mut RegorusEngine, -) -> RegorusResult { - let output = || -> Result { - serde_json::to_string_pretty(&to_ref(engine)?.engine.get_policy_parameters()?) - .map_err(anyhow::Error::msg) - }(); - match output { - Ok(out) => RegorusResult { - status: RegorusStatus::RegorusStatusOk, - output: to_c_str(out), - error_message: std::ptr::null_mut(), - }, - Err(e) => to_regorus_result(Err(e)), - } -} - -/// Enable/disable rego v1. -/// -/// See https://docs.rs/regorus/latest/regorus/struct.Engine.html#method.set_rego_v0 -#[no_mangle] -pub extern "C" fn regorus_engine_set_rego_v0( - engine: *mut RegorusEngine, - enable: bool, -) -> RegorusResult { - let output = || -> Result<()> { - to_ref(engine)?.engine.set_rego_v0(enable); - Ok(()) - }(); - match output { - Ok(()) => RegorusResult { - status: RegorusStatus::RegorusStatusOk, - output: std::ptr::null_mut(), - error_message: std::ptr::null_mut(), - }, - Err(e) => to_regorus_result(Err(e)), - } -} - -#[cfg(feature = "custom_allocator")] -extern "C" { - fn regorus_aligned_alloc(alignment: usize, size: usize) -> *mut u8; - fn regorus_free(ptr: *mut u8); -} - -#[cfg(feature = "custom_allocator")] -mod allocator { - use std::alloc::{GlobalAlloc, Layout}; - - struct RegorusAllocator {} - - unsafe impl GlobalAlloc for RegorusAllocator { - unsafe fn alloc(&self, layout: Layout) -> *mut u8 { - let size = layout.size(); - let align = layout.align(); - - crate::regorus_aligned_alloc(align, size) - } - - unsafe fn dealloc(&self, ptr: *mut u8, _layout: Layout) { - crate::regorus_free(ptr) - } - } - - #[global_allocator] - static ALLOCATOR: RegorusAllocator = RegorusAllocator {}; -} +mod allocator; +mod common; +mod compile; +mod compiled_policy; +mod effect_registry; +mod engine; +mod schema_registry; +mod target_registry; diff --git a/bindings/ffi/src/schema_registry.rs b/bindings/ffi/src/schema_registry.rs new file mode 100644 index 00000000..a7bf50cd --- /dev/null +++ b/bindings/ffi/src/schema_registry.rs @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Schema registry functions for FFI. +//! +//! These functions provide access to regorus's resource schema registry functionality, +//! enabling registration and management of Azure Policy resource schemas. + +#![cfg(feature = "azure_policy")] + +use crate::common::{from_c_str, RegorusResult, RegorusStatus}; +use regorus::{registry::schemas, Schema}; + +use std::os::raw::c_char; + +// Resource Schema Registry Functions + +/// Register a resource schema from JSON with a given name. +/// +/// # Parameters +/// * `name` - Name to register the schema under +/// * `schema_json` - JSON string representing the schema +/// +/// # Returns +/// Returns a RegorusResult with success/error status. +/// +/// # Safety +/// All string parameters must be valid null-terminated UTF-8 strings. +#[cfg(feature = "azure_policy")] +#[no_mangle] +pub extern "C" fn regorus_resource_schema_register( + name: *const c_char, + schema_json: *const c_char, +) -> RegorusResult { + let schema_name = match from_c_str(name) { + Ok(s) => s, + Err(e) => { + return RegorusResult::err_with_message( + RegorusStatus::InvalidArgument, + format!("Invalid schema name string: {e}"), + ) + } + }; + + let schema_str = match from_c_str(schema_json) { + Ok(s) => s, + Err(e) => { + return RegorusResult::err_with_message( + RegorusStatus::InvalidDataFormat, + format!("Invalid schema JSON string: {e}"), + ) + } + }; + + // Parse schema from JSON + let schema = match Schema::from_json_str(&schema_str) { + Ok(schema) => schema, + Err(e) => { + return RegorusResult::err_with_message( + RegorusStatus::InvalidDataFormat, + format!("Failed to parse schema JSON: {e}"), + ) + } + }; + + // Register the schema + match schemas::resource::register(schema_name, schema.into()) { + Ok(()) => RegorusResult::ok_pointer(std::ptr::null_mut()), + Err(e) => RegorusResult::err_with_message( + RegorusStatus::Error, + format!("Failed to register schema: {e}"), + ), + } +} + +/// Check if a resource schema with the given name exists. +/// +/// # Parameters +/// * `name` - Name of the schema to check +/// +/// # Returns +/// Returns a RegorusResult with "true" or "false" string output. +/// +/// # Safety +/// The name parameter must be a valid null-terminated UTF-8 string. +#[cfg(feature = "azure_policy")] +#[no_mangle] +pub extern "C" fn regorus_resource_schema_contains(name: *const c_char) -> RegorusResult { + let schema_name = match from_c_str(name) { + Ok(s) => s, + Err(e) => { + return RegorusResult::err_with_message( + RegorusStatus::InvalidArgument, + format!("Invalid schema name string: {e}"), + ) + } + }; + + let contains = schemas::resource::contains(&schema_name); + RegorusResult::ok_bool(contains) +} + +/// Get the number of registered resource schemas. +/// +/// # Returns +/// Returns a RegorusResult with the count as a string. +#[cfg(feature = "azure_policy")] +#[no_mangle] +pub extern "C" fn regorus_resource_schema_len() -> RegorusResult { + let count = schemas::resource::len(); + RegorusResult::ok_int(count as i64) +} + +/// Check if the resource schema registry is empty. +/// +/// # Returns +/// Returns a RegorusResult with "true" or "false" string output. +#[cfg(feature = "azure_policy")] +#[no_mangle] +pub extern "C" fn regorus_resource_schema_is_empty() -> RegorusResult { + let is_empty = schemas::resource::is_empty(); + RegorusResult::ok_bool(is_empty) +} + +/// List all registered resource schema names as a JSON array. +/// +/// # Returns +/// Returns a RegorusResult with a JSON array of schema names. +#[cfg(feature = "azure_policy")] +#[no_mangle] +pub extern "C" fn regorus_resource_schema_list_names() -> RegorusResult { + let names = schemas::resource::list_names(); + match serde_json::to_string(&names) { + Ok(json_str) => RegorusResult::ok_string(json_str), + Err(e) => RegorusResult::err_with_message( + RegorusStatus::Error, + format!("Failed to serialize schema names to JSON: {e}"), + ), + } +} + +/// Remove a resource schema by name. +/// +/// # Parameters +/// * `name` - Name of the schema to remove +/// +/// # Returns +/// Returns a RegorusResult with "true" if removed, "false" if not found. +/// +/// # Safety +/// The name parameter must be a valid null-terminated UTF-8 string. +#[cfg(feature = "azure_policy")] +#[no_mangle] +pub extern "C" fn regorus_resource_schema_remove(name: *const c_char) -> RegorusResult { + let schema_name = match from_c_str(name) { + Ok(s) => s, + Err(e) => { + return RegorusResult::err_with_message( + RegorusStatus::InvalidArgument, + format!("Invalid schema name string: {e}"), + ) + } + }; + + let removed = schemas::resource::remove(&schema_name).is_some(); + RegorusResult::ok_bool(removed) +} + +/// Clear all resource schemas from the registry. +/// +/// # Returns +/// Returns a RegorusResult with success status. +#[cfg(feature = "azure_policy")] +#[no_mangle] +pub extern "C" fn regorus_resource_schema_clear() -> RegorusResult { + schemas::resource::clear(); + RegorusResult::ok_pointer(std::ptr::null_mut()) +} diff --git a/bindings/ffi/src/target_registry.rs b/bindings/ffi/src/target_registry.rs new file mode 100644 index 00000000..8ca2a440 --- /dev/null +++ b/bindings/ffi/src/target_registry.rs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#![cfg(feature = "azure_policy")] + +use crate::common::*; +use anyhow::Result; +use std::os::raw::c_char; + +/// Register a target from JSON definition. +/// +/// The target JSON should follow the target schema format. +/// Once registered, the target can be referenced in Rego policies using `__target__` rules. +/// +/// * `target_json`: JSON encoded target definition +#[no_mangle] +#[cfg(feature = "azure_policy")] +pub extern "C" fn regorus_register_target_from_json(target_json: *const c_char) -> RegorusResult { + to_regorus_result(|| -> Result<()> { + let target_str = from_c_str(target_json)?; + let target = regorus::Target::from_json_str(&target_str)?; + regorus::registry::targets::register(regorus::Rc::new(target))?; + Ok(()) + }()) +} + +/// Check if a target is registered. +/// +/// # Parameters +/// * `name` - Name of the target to check +/// +/// # Returns +/// Returns a RegorusResult with boolean value indicating if the target is registered. +/// +/// # Safety +/// The name parameter must be a valid null-terminated UTF-8 string. +#[no_mangle] +pub extern "C" fn regorus_target_registry_contains(name: *const c_char) -> RegorusResult { + let target_name = match from_c_str(name) { + Ok(s) => s, + Err(e) => { + return RegorusResult::err_with_message( + RegorusStatus::InvalidArgument, + format!("Invalid target name string: {e}"), + ) + } + }; + + let contains = regorus::registry::targets::contains(&target_name); + RegorusResult::ok_bool(contains) +} + +/// Get a list of all registered target names as JSON array. +#[no_mangle] +#[cfg(feature = "azure_policy")] +pub extern "C" fn regorus_target_registry_list_names() -> RegorusResult { + let names = regorus::registry::targets::list_names(); + let output = serde_json::to_string_pretty(&names).map_err(anyhow::Error::msg); + + match output { + Ok(out) => RegorusResult::ok_string(out), + Err(e) => to_regorus_result(Err(e)), + } +} + +/// Remove a target from the registry by name. +/// +/// * `name`: The target name to remove +#[no_mangle] +#[cfg(feature = "azure_policy")] +pub extern "C" fn regorus_target_registry_remove(name: *const c_char) -> RegorusResult { + to_regorus_result(|| -> Result<()> { + let name_str = from_c_str(name)?; + regorus::registry::targets::remove(&name_str); + Ok(()) + }()) +} + +/// Clear all targets from the registry. +#[no_mangle] +#[cfg(feature = "azure_policy")] +pub extern "C" fn regorus_target_registry_clear() -> RegorusResult { + regorus::registry::targets::clear(); + RegorusResult::ok_void() +} + +/// Get the number of registered targets. +/// +/// # Returns +/// Returns a RegorusResult with the count as an integer value. +#[no_mangle] +#[cfg(feature = "azure_policy")] +pub extern "C" fn regorus_target_registry_len() -> RegorusResult { + let count = regorus::registry::targets::len(); + RegorusResult::ok_int(count as i64) +} + +/// Check if the target registry is empty. +/// +/// # Returns +/// Returns a RegorusResult with boolean value indicating if the registry is empty. +#[no_mangle] +#[cfg(feature = "azure_policy")] +pub extern "C" fn regorus_target_registry_is_empty() -> RegorusResult { + let is_empty = regorus::registry::targets::is_empty(); + RegorusResult::ok_bool(is_empty) +} diff --git a/bindings/go/pkg/regorus/mod.go b/bindings/go/pkg/regorus/mod.go index 6270e5b0..cf945778 100644 --- a/bindings/go/pkg/regorus/mod.go +++ b/bindings/go/pkg/regorus/mod.go @@ -32,7 +32,7 @@ func (e *Engine) SetRegoV0(enable bool) error { result := C.regorus_engine_set_rego_v0(e.e, C.bool(enable)) defer C.regorus_result_drop(result) - if result.status != C.RegorusStatusOk { + if result.status != C.Ok { return fmt.Errorf("%s", C.GoString(result.error_message)) } @@ -48,7 +48,7 @@ func (e *Engine) AddPolicy(path string, rego string) (string, error) { result := C.regorus_engine_add_policy(e.e, path_c, rego_c) defer C.regorus_result_drop(result) - if result.status != C.RegorusStatusOk { + if result.status != C.Ok { return "", fmt.Errorf("%s", C.GoString(result.error_message)) } return C.GoString(result.output), nil @@ -60,7 +60,7 @@ func (e *Engine) AddPolicyFromFile(path string) (string, error) { result := C.regorus_engine_add_policy_from_file(e.e, path_c) defer C.regorus_result_drop(result) - if result.status != C.RegorusStatusOk { + if result.status != C.Ok { return "", fmt.Errorf("%s", C.GoString(result.error_message)) } return C.GoString(result.output), nil @@ -69,7 +69,7 @@ func (e *Engine) AddPolicyFromFile(path string) (string, error) { func (e *Engine) GetPackages() (string, error) { result := C.regorus_engine_get_packages(e.e) defer C.regorus_result_drop(result) - if result.status != C.RegorusStatusOk { + if result.status != C.Ok { return "", fmt.Errorf("%s", C.GoString(result.error_message)) } return C.GoString(result.output), nil @@ -78,7 +78,7 @@ func (e *Engine) GetPackages() (string, error) { func (e *Engine) GetPolicies() (string, error) { result := C.regorus_engine_get_policies(e.e) defer C.regorus_result_drop(result) - if result.status != C.RegorusStatusOk { + if result.status != C.Ok { return "", fmt.Errorf("%s", C.GoString(result.error_message)) } return C.GoString(result.output), nil @@ -90,7 +90,7 @@ func (e *Engine) AddDataJson(data string) error { result := C.regorus_engine_add_data_json(e.e, data_c) defer C.regorus_result_drop(result) - if result.status != C.RegorusStatusOk { + if result.status != C.Ok { return fmt.Errorf("%s", C.GoString(result.error_message)) } return nil @@ -102,7 +102,7 @@ func (e *Engine) AddDataFromJsonFile(path string) error { result := C.regorus_engine_add_data_from_json_file(e.e, path_c) defer C.regorus_result_drop(result) - if result.status != C.RegorusStatusOk { + if result.status != C.Ok { return fmt.Errorf("%s", C.GoString(result.error_message)) } return nil @@ -114,7 +114,7 @@ func (e *Engine) SetInputJson(input string) error { result := C.regorus_engine_set_input_json(e.e, input_c) defer C.regorus_result_drop(result) - if result.status != C.RegorusStatusOk { + if result.status != C.Ok { return fmt.Errorf("%s", C.GoString(result.error_message)) } return nil @@ -126,7 +126,7 @@ func (e *Engine) SetInputFromJsonFile(path string) error { result := C.regorus_engine_set_input_from_json_file(e.e, path_c) defer C.regorus_result_drop(result) - if result.status != C.RegorusStatusOk { + if result.status != C.Ok { return fmt.Errorf("%s", C.GoString(result.error_message)) } return nil @@ -138,7 +138,7 @@ func (e *Engine) EvalQuery(query string) (string, error) { result := C.regorus_engine_eval_query(e.e, query_c) defer C.regorus_result_drop(result) - if result.status != C.RegorusStatusOk { + if result.status != C.Ok { return "", fmt.Errorf("%s", C.GoString(result.error_message)) } @@ -151,7 +151,7 @@ func (e *Engine) EvalRule(rule string) (string, error) { result := C.regorus_engine_eval_rule(e.e, rule_c) defer C.regorus_result_drop(result) - if result.status != C.RegorusStatusOk { + if result.status != C.Ok { return "", fmt.Errorf("%s", C.GoString(result.error_message)) } @@ -161,7 +161,7 @@ func (e *Engine) EvalRule(rule string) (string, error) { func (e *Engine) SetEnableCoverage(enable bool) error { result := C.regorus_engine_set_enable_coverage(e.e, C.bool(enable)) defer C.regorus_result_drop(result) - if result.status != C.RegorusStatusOk { + if result.status != C.Ok { return fmt.Errorf("%s", C.GoString(result.error_message)) } return nil @@ -170,7 +170,7 @@ func (e *Engine) SetEnableCoverage(enable bool) error { func (e *Engine) ClearCoverageData() error { result := C.regorus_engine_clear_coverage_data(e.e) defer C.regorus_result_drop(result) - if result.status != C.RegorusStatusOk { + if result.status != C.Ok { return fmt.Errorf("%s", C.GoString(result.error_message)) } return nil @@ -179,7 +179,7 @@ func (e *Engine) ClearCoverageData() error { func (e *Engine) GetCoverageReport() (string, error) { result := C.regorus_engine_get_coverage_report(e.e) defer C.regorus_result_drop(result) - if result.status != C.RegorusStatusOk { + if result.status != C.Ok { return "", fmt.Errorf("%s", C.GoString(result.error_message)) } @@ -189,7 +189,7 @@ func (e *Engine) GetCoverageReport() (string, error) { func (e *Engine) GetCoverageReportPretty() (string, error) { result := C.regorus_engine_get_coverage_report_pretty(e.e) defer C.regorus_result_drop(result) - if result.status != C.RegorusStatusOk { + if result.status != C.Ok { return "", fmt.Errorf("%s", C.GoString(result.error_message)) } @@ -199,7 +199,7 @@ func (e *Engine) GetCoverageReportPretty() (string, error) { func (e *Engine) SetGatherPrints(b bool) error { result := C.regorus_engine_set_gather_prints(e.e, C.bool(b)) defer C.regorus_result_drop(result) - if result.status != C.RegorusStatusOk { + if result.status != C.Ok { return fmt.Errorf("%s", C.GoString(result.error_message)) } return nil @@ -208,7 +208,7 @@ func (e *Engine) SetGatherPrints(b bool) error { func (e *Engine) TakePrints() (string, error) { result := C.regorus_engine_take_prints(e.e) defer C.regorus_result_drop(result) - if result.status != C.RegorusStatusOk { + if result.status != C.Ok { return "", fmt.Errorf("%s", C.GoString(result.error_message)) } diff --git a/bindings/java/Cargo.lock b/bindings/java/Cargo.lock index a19b869e..dd24a455 100644 --- a/bindings/java/Cargo.lock +++ b/bindings/java/Cargo.lock @@ -42,9 +42,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" [[package]] name = "autocfg" @@ -435,7 +435,7 @@ dependencies = [ "combine", "jni-sys", "log", - "thiserror", + "thiserror 1.0.69", "walkdir", "windows-sys 0.45.0", ] @@ -689,9 +689,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.96" +version = "1.0.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "beef09f85ae72cea1ef96ba6870c51e6382ebfa4f0e85b643459331f3daa5be0" +checksum = "d61789d7719defeb74ea5fe81f2fdfdbd28a803847077cecce2ff14e1472f6f1" dependencies = [ "unicode-ident", ] @@ -831,6 +831,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "thiserror 2.0.14", "url", "uuid", ] @@ -969,9 +970,9 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] name = "syn" -version = "2.0.104" +version = "2.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "7bc3fcb250e53458e712715cf74285c1f889686520d79294a9ef3bd7aa1fc619" dependencies = [ "proc-macro2", "quote", @@ -995,7 +996,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b0949c3a6c842cbde3f1686d6eea5a010516deb7085f79db747562d4102f41e" +dependencies = [ + "thiserror-impl 2.0.14", ] [[package]] @@ -1009,6 +1019,17 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror-impl" +version = "2.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc5b44b4ab9c2fdd0e0512e6bece8388e214c0749f5862b114cc5b7a25daf227" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tinystr" version = "0.8.1" diff --git a/bindings/python/Cargo.lock b/bindings/python/Cargo.lock index 38275d6d..714923a4 100644 --- a/bindings/python/Cargo.lock +++ b/bindings/python/Cargo.lock @@ -42,9 +42,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" [[package]] name = "autocfg" @@ -681,9 +681,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.96" +version = "1.0.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "beef09f85ae72cea1ef96ba6870c51e6382ebfa4f0e85b643459331f3daa5be0" +checksum = "d61789d7719defeb74ea5fe81f2fdfdbd28a803847077cecce2ff14e1472f6f1" dependencies = [ "unicode-ident", ] @@ -887,6 +887,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "thiserror", "url", "uuid", ] @@ -1017,9 +1018,9 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] name = "syn" -version = "2.0.104" +version = "2.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "7bc3fcb250e53458e712715cf74285c1f889686520d79294a9ef3bd7aa1fc619" dependencies = [ "proc-macro2", "quote", @@ -1043,6 +1044,26 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" +[[package]] +name = "thiserror" +version = "2.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b0949c3a6c842cbde3f1686d6eea5a010516deb7085f79db747562d4102f41e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc5b44b4ab9c2fdd0e0512e6bece8388e214c0749f5862b114cc5b7a25daf227" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tinystr" version = "0.8.1" diff --git a/bindings/ruby/Cargo.lock b/bindings/ruby/Cargo.lock index 55b7c9e0..9ccb7958 100644 --- a/bindings/ruby/Cargo.lock +++ b/bindings/ruby/Cargo.lock @@ -42,9 +42,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" [[package]] name = "autocfg" @@ -761,9 +761,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.96" +version = "1.0.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "beef09f85ae72cea1ef96ba6870c51e6382ebfa4f0e85b643459331f3daa5be0" +checksum = "d61789d7719defeb74ea5fe81f2fdfdbd28a803847077cecce2ff14e1472f6f1" dependencies = [ "unicode-ident", ] @@ -933,6 +933,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "thiserror", "url", "uuid", ] @@ -1091,9 +1092,9 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] name = "syn" -version = "2.0.104" +version = "2.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "7bc3fcb250e53458e712715cf74285c1f889686520d79294a9ef3bd7aa1fc619" dependencies = [ "proc-macro2", "quote", @@ -1117,6 +1118,26 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "thiserror" +version = "2.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b0949c3a6c842cbde3f1686d6eea5a010516deb7085f79db747562d4102f41e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc5b44b4ab9c2fdd0e0512e6bece8388e214c0749f5862b114cc5b7a25daf227" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tinystr" version = "0.8.1" diff --git a/bindings/wasm/Cargo.lock b/bindings/wasm/Cargo.lock index 440716d9..b2fd31e5 100644 --- a/bindings/wasm/Cargo.lock +++ b/bindings/wasm/Cargo.lock @@ -42,9 +42,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" [[package]] name = "autocfg" @@ -670,9 +670,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.96" +version = "1.0.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "beef09f85ae72cea1ef96ba6870c51e6382ebfa4f0e85b643459331f3daa5be0" +checksum = "d61789d7719defeb74ea5fe81f2fdfdbd28a803847077cecce2ff14e1472f6f1" dependencies = [ "unicode-ident", ] @@ -812,6 +812,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "thiserror", "url", "uuid", ] @@ -953,9 +954,9 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] name = "syn" -version = "2.0.104" +version = "2.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "7bc3fcb250e53458e712715cf74285c1f889686520d79294a9ef3bd7aa1fc619" dependencies = [ "proc-macro2", "quote", @@ -973,6 +974,26 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror" +version = "2.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b0949c3a6c842cbde3f1686d6eea5a010516deb7085f79db747562d4102f41e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc5b44b4ab9c2fdd0e0512e6bece8388e214c0749f5862b114cc5b7a25daf227" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tinystr" version = "0.8.1" diff --git a/src/ast.rs b/src/ast.rs index 96f8107f..4e16f500 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -465,6 +465,9 @@ pub struct Module { #[cfg_attr(feature = "ast", serde(rename(serialize = "rules")))] pub policy: Vec>, pub rego_v1: bool, + // Target name if specified via __target__ rule + #[cfg_attr(feature = "ast", serde(skip_serializing_if = "Option::is_none"))] + pub target: Option, // Number of expressions in the module. pub num_expressions: u32, // Number of statements in the module. diff --git a/src/builtins/strings.rs b/src/builtins/strings.rs index 546a4a2b..9479e184 100644 --- a/src/builtins/strings.rs +++ b/src/builtins/strings.rs @@ -280,19 +280,11 @@ fn sprintf(span: &Span, params: &[Ref], args: &[Value], _strict: bool) -> args_idx += 1; // Handle Golang flags. - let mut emit_sign = false; - let mut leave_space_for_elided_sign = false; - match chars.peek() { - Some('+') => { - emit_sign = true; - chars.next(); - } - Some(' ') => { - leave_space_for_elided_sign = true; - chars.next(); - } - _ => (), - } + let emit_sign = false; + let leave_space_for_elided_sign = false; + // Note: Golang flags come BEFORE the format verb, not after. + // This code was incorrectly consuming characters after the verb. + // Removing the incorrect flag handling to fix sprintf spacing. let get_sign_value = |f: &Number| match (emit_sign, f) { (_, v) if v < &Number::from(0.0) => ("-", v.clone()), diff --git a/src/compile.rs b/src/compile.rs new file mode 100644 index 00000000..23c70d0d --- /dev/null +++ b/src/compile.rs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::compiled_policy::CompiledPolicy; +use crate::engine::Engine; +use crate::value::Value; +use crate::*; + +use anyhow::Result; + +/// Represents a Rego policy module with an identifier and content. +#[derive(Debug, Clone)] +pub struct PolicyModule { + pub id: Rc, + pub content: Rc, +} + +/// Compiles a target-aware policy from data and modules. +/// +/// This is a convenience function that sets up an [`Engine`] and calls +/// [`Engine::compile_for_target`]. For more control over the compilation process +/// or to reuse an engine, use the engine method directly. +/// +/// # Arguments +/// +/// * `data` - Static data to be available during policy evaluation +/// * `modules` - Array of Rego policy modules to compile together +/// +/// # Returns +/// +/// Returns a [`CompiledPolicy`] for target-aware evaluation. +/// +/// # Note +/// +/// This function is only available when the `azure_policy` feature is enabled. +/// +/// # See Also +/// +/// - [`Engine::compile_for_target`] for detailed documentation and examples +/// - [`compile_policy_with_entrypoint`] for explicit rule-based compilation +#[cfg(feature = "azure_policy")] +#[cfg_attr(docsrs, doc(cfg(feature = "azure_policy")))] +pub fn compile_policy_for_target(data: Value, modules: &[PolicyModule]) -> Result { + let mut engine = setup_engine_with_modules(data, modules)?; + engine.compile_for_target() +} + +/// Compiles a policy from data and modules with a specific entry point rule. +/// +/// This is a convenience function that sets up an [`Engine`] and calls +/// [`Engine::compile_with_entrypoint`]. For more control over the compilation process +/// or to reuse an engine, use the engine method directly. +/// +/// # Arguments +/// +/// * `data` - Static data to be available during policy evaluation +/// * `modules` - Array of Rego policy modules to compile together +/// * `entry_point_rule` - The specific rule path to evaluate (e.g., "data.policy.allow") +/// +/// # Returns +/// +/// Returns a [`CompiledPolicy`] focused on the specified entry point rule. +/// +/// # See Also +/// +/// - [`Engine::compile_with_entrypoint`] for detailed documentation and examples +/// - [`compile_policy_for_target`] for target-aware compilation +pub fn compile_policy_with_entrypoint( + data: Value, + modules: &[PolicyModule], + entry_point_rule: Rc, +) -> Result { + let mut engine = setup_engine_with_modules(data, modules)?; + engine.compile_with_entrypoint(&entry_point_rule) +} + +/// Helper function to set up an engine with data and modules. +fn setup_engine_with_modules(data: Value, modules: &[PolicyModule]) -> Result { + let mut engine = Engine::new(); + + // Add data to the engine + engine.add_data(data)?; + engine.set_gather_prints(true); + + // Add all modules to the engine + for module in modules { + engine.add_policy(module.id.to_string(), module.content.to_string())?; + } + + Ok(engine) +} diff --git a/src/compiled_policy.rs b/src/compiled_policy.rs new file mode 100644 index 00000000..213e448c --- /dev/null +++ b/src/compiled_policy.rs @@ -0,0 +1,215 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::ast::*; +use crate::engine::Engine; +use crate::scheduler::*; +use crate::utils::*; +use crate::*; + +use alloc::collections::BTreeMap; +use anyhow::Result; + +#[cfg(feature = "azure_policy")] +use crate::target::Target; + +pub(crate) type DefaultRuleInfo = (Ref, Option); + +#[cfg(feature = "azure_policy")] +pub(crate) type ResourceTypeInfo = (Rc, Rc); + +#[cfg(feature = "azure_policy")] +pub(crate) type InferredResourceTypes = BTreeMap, ResourceTypeInfo>; + +/// Wrapper around CompiledPolicyData that holds an Rc reference. +#[derive(Debug, Clone)] +pub struct CompiledPolicy { + inner: Rc, +} + +impl CompiledPolicy { + /// Create a new CompiledPolicy from CompiledPolicyData. + pub(crate) fn new(inner: Rc) -> Self { + Self { inner } + } +} + +impl CompiledPolicy { + /// Evaluate the compiled policy with the given input. + /// + /// For target policies, evaluates the target's effect rule. + /// For regular policies, evaluates the originally compiled rule. + /// + /// * `input`: Input data (resource) to validate against the policy. + /// + /// Returns the result of evaluating the rule. + pub fn eval_with_input(&self, input: Value) -> Result { + let mut engine = Engine::new_from_compiled_policy(self.inner.clone()); + + // Set input + engine.set_input(input); + + // Evaluate the rule + #[cfg(feature = "azure_policy")] + if let Some(target_info) = self.inner.target_info.as_ref() { + return engine.eval_rule(target_info.effect_path.to_string()); + } + engine.eval_rule(self.inner.rule_to_evaluate.to_string()) + } + + /// Get information about the compiled policy including metadata about modules, + /// target configuration, and resource types. + /// + /// Returns a [`crate::policy_info::PolicyInfo`] struct containing comprehensive + /// information about the compiled policy such as module IDs, target name, + /// applicable resource types, entry point rule, and parameters. + /// + /// # Examples + /// + /// ```no_run + /// use regorus::*; + /// # use std::sync::Arc; + /// + /// # fn main() -> anyhow::Result<()> { + /// # // Register a target for the example + /// # #[cfg(feature = "azure_policy")] + /// # { + /// # let target = regorus::target::Target::from_json_file("tests/interpreter/cases/target/definitions/sample_target.json")?; + /// # regorus::registry::targets::register(std::sync::Arc::new(target))?; + /// # } + /// + /// // Compile the policy + /// let policy_rego = r#" + /// package policy.example + /// import rego.v1 + /// __target__ := "target.tests.sample_test_target" + /// + /// effect := "allow" if { + /// input.type == "storage_account" + /// input.location in ["eastus", "westus"] + /// } + /// "#; + /// + /// let modules = vec![regorus::PolicyModule { + /// id: "policy.rego".into(), + /// content: policy_rego.into(), + /// }]; + /// + /// #[cfg(feature = "azure_policy")] + /// let compiled = regorus::compile_policy_for_target(Value::new_object(), &modules)?; + /// #[cfg(not(feature = "azure_policy"))] + /// let compiled = regorus::compile_policy_with_entrypoint(Value::new_object(), &modules, "allow".into())?; + /// let info = compiled.get_policy_info()?; + /// + /// assert_eq!(info.target_name, Some("target.tests.sample_test_target".into())); + /// assert_eq!(info.effect_rule, Some("effect".into())); + /// assert!(info.module_ids.len() > 0); + /// # Ok(()) + /// # } + /// ``` + pub fn get_policy_info(&self) -> Result { + // Extract module IDs from the compiled policy + let module_ids: Vec> = self + .inner + .modules + .iter() + .enumerate() + .map(|(i, module)| { + // Use source file path if available, otherwise generate an ID + let source_path = module.package.span.source.get_path(); + if source_path.is_empty() { + format!("module_{}", i).into() + } else { + source_path.clone().into() + } + }) + .collect(); + + // Extract target name and effect rule + #[cfg(feature = "azure_policy")] + let (target_name, effect_rule) = if let Some(target_info) = &self.inner.target_info { + ( + Some(target_info.target.name.clone()), + Some(target_info.effect_name.clone()), + ) + } else { + (None, None) + }; + + #[cfg(not(feature = "azure_policy"))] + let (target_name, effect_rule) = (None, None); + + // Extract applicable resource types from inferred types + #[cfg(feature = "azure_policy")] + let applicable_resource_types: Vec> = + if let Some(inferred_types) = &self.inner.inferred_resource_types { + inferred_types + .values() + .map(|(resource_type, _schema)| resource_type.clone()) + .collect::>() // Remove duplicates + .into_iter() + .collect() + } else { + Vec::new() + }; + + #[cfg(not(feature = "azure_policy"))] + let applicable_resource_types: Vec> = Vec::new(); + + // Get parameters from the modules + #[cfg(feature = "azure_policy")] + let parameters = { + // Create a new engine from the compiled modules to extract parameters + let temp_engine = crate::engine::Engine::new_from_compiled_policy(self.inner.clone()); + + temp_engine.get_policy_parameters()? + }; + + Ok(crate::policy_info::PolicyInfo { + module_ids, + target_name, + applicable_resource_types, + entrypoint_rule: self.inner.rule_to_evaluate.clone(), + effect_rule, + #[cfg(feature = "azure_policy")] + parameters, + }) + } +} + +#[cfg(feature = "azure_policy")] +#[derive(Debug, Clone)] +pub(crate) struct TargetInfo { + pub(crate) target: Rc, + pub(crate) package: String, + pub(crate) effect_schema: Rc, + pub(crate) effect_name: Rc, + pub(crate) effect_path: Rc, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct CompiledPolicyData { + pub(crate) modules: Rc>>, + pub(crate) schedule: Option, + pub(crate) rules: Map>>, + pub(crate) default_rules: Map>, + pub(crate) imports: BTreeMap>, + pub(crate) functions: FunctionTable, + pub(crate) rule_paths: Set, + #[cfg(feature = "azure_policy")] + pub(crate) target_info: Option, + #[cfg(feature = "azure_policy")] + pub(crate) inferred_resource_types: Option, + + // User-defined rule to evaluate + pub(crate) rule_to_evaluate: Rc, + + // User-defined data + pub(crate) data: Option, + + // Evaluation settings + pub(crate) strict_builtin_errors: bool, + + // The semantics of extensions ought to be changes to be more Clone friendly. + pub(crate) extensions: Map>)>, +} diff --git a/src/engine.rs b/src/engine.rs index b33aff48..19c14e45 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. use crate::ast::*; +use crate::compiled_policy::CompiledPolicy; use crate::interpreter::*; use crate::lexer::*; use crate::parser::*; @@ -346,7 +347,7 @@ impl Engine { /// Get the data document. /// /// The returned value is the data document that has been constructed using - /// one or more calls to [`Engine::add_data`]. The values of policy rules are + /// one or more calls to [`Engine::pre`]. The values of policy rules are /// not included in the returned document. /// /// @@ -397,6 +398,259 @@ impl Engine { &self.modules } + /// Compiles a target-aware policy from the current engine state. + /// + /// This method creates a compiled policy that can work with Azure Policy targets, + /// enabling resource type inference and target-specific evaluation. The compiled + /// policy will automatically detect and handle `__target__` declarations in the + /// loaded modules. + /// + /// The engine must have been prepared with: + /// - Policy modules added via [`Engine::add_policy`] + /// - Data added via [`Engine::add_data`] (optional) + /// + /// # Returns + /// + /// Returns a [`CompiledPolicy`] that can be used for efficient policy evaluation + /// with target support, including resource type inference capabilities. + /// + /// # Examples + /// + /// ## Basic Target-Aware Compilation + /// + /// ```no_run + /// use regorus::*; + /// + /// # fn main() -> anyhow::Result<()> { + /// let mut engine = Engine::new(); + /// engine.add_data(Value::from_json_str(r#"{"allowed_sizes": ["small", "medium"]}"#)?)?; + /// engine.add_policy("policy.rego".to_string(), r#" + /// package policy.test + /// import rego.v1 + /// __target__ := "target.tests.sample_test_target" + /// + /// default allow := false + /// allow if { + /// input.type == "vm" + /// input.size in data.allowed_sizes + /// } + /// "#.to_string())?; + /// + /// let compiled = engine.compile_for_target()?; + /// let result = compiled.eval_with_input(Value::from_json_str(r#"{"type": "vm", "size": "small"}"#)?)?; + /// # Ok(()) + /// # } + /// ``` + /// + /// ## Target Registration and Usage + /// + /// ```no_run + /// use regorus::*; + /// use regorus::registry::targets; + /// use regorus::target::Target; + /// use std::sync::Arc; + /// + /// # fn main() -> anyhow::Result<()> { + /// // Register a target first + /// let target_json = r#" + /// { + /// "name": "target.example.vm_policy", + /// "description": "Simple VM validation target", + /// "version": "1.0.0", + /// "resource_schema_selector": "type", + /// "resource_schemas": [ + /// { + /// "type": "object", + /// "properties": { + /// "name": { "type": "string" }, + /// "type": { "const": "vm" }, + /// "size": { "enum": ["small", "medium", "large"] } + /// }, + /// "required": ["name", "type", "size"] + /// } + /// ], + /// "effects": { + /// "allow": { "type": "boolean" }, + /// "deny": { "type": "boolean" } + /// } + /// } + /// "#; + /// + /// let target = Target::from_json_str(target_json)?; + /// targets::register(Arc::new(target))?; + /// + /// // Use the target in a policy + /// let mut engine = Engine::new(); + /// engine.add_data(Value::from_json_str(r#"{"allowed_locations": ["us-east"]}"#)?)?; + /// engine.add_policy("vm_policy.rego".to_string(), r#" + /// package vm.validation + /// import rego.v1 + /// __target__ := "target.example.vm_policy" + /// + /// default allow := false + /// allow if { + /// input.type == "vm" + /// input.size in ["small", "medium"] + /// } + /// "#.to_string())?; + /// + /// let compiled = engine.compile_for_target()?; + /// let result = compiled.eval_with_input(Value::from_json_str(r#" + /// { + /// "name": "test-vm", + /// "type": "vm", + /// "size": "small" + /// }"#)?)?; + /// assert_eq!(result, Value::from(true)); + /// # Ok(()) + /// # } + /// ``` + /// + /// # Notes + /// + /// - This method is only available when the `azure_policy` feature is enabled + /// - Automatically enables print gathering for debugging purposes + /// - Requires that at least one module contains a `__target__` declaration + /// - The target referenced must be registered in the target registry + /// + /// # See Also + /// + /// - [`Engine::compile_with_entrypoint`] for explicit rule-based compilation + /// - [`crate::compile_policy_for_target`] for a higher-level convenience function + #[cfg(feature = "azure_policy")] + #[cfg_attr(docsrs, doc(cfg(feature = "azure_policy")))] + pub fn compile_for_target(&mut self) -> Result { + self.prepare_for_eval(false, true)?; + self.interpreter.clean_internal_evaluation_state(); + self.interpreter.compile(None).map(CompiledPolicy::new) + } + + /// Compiles a policy with a specific entry point rule. + /// + /// This method creates a compiled policy that evaluates a specific rule as the entry point. + /// Unlike [`Engine::compile_for_target`], this method requires you to explicitly specify which + /// rule should be evaluated and does not automatically handle target-specific features. + /// + /// The engine must have been prepared with: + /// - Policy modules added via [`Engine::add_policy`] + /// - Data added via [`Engine::add_data`] (optional) + /// + /// # Arguments + /// + /// * `rule` - The specific rule path to evaluate (e.g., "data.policy.allow") + /// + /// # Returns + /// + /// Returns a [`CompiledPolicy`] that can be used for efficient policy evaluation + /// focused on the specified entry point rule. + /// + /// # Examples + /// + /// ## Basic Usage + /// + /// ```no_run + /// use regorus::*; + /// use std::rc::Rc; + /// + /// # fn main() -> anyhow::Result<()> { + /// let mut engine = Engine::new(); + /// engine.add_data(Value::from_json_str(r#"{"allowed_users": ["alice", "bob"]}"#)?)?; + /// engine.add_policy("authz.rego".to_string(), r#" + /// package authz + /// import rego.v1 + /// + /// default allow := false + /// allow if { + /// input.user in data.allowed_users + /// input.action == "read" + /// } + /// + /// deny if { + /// input.user == "guest" + /// } + /// "#.to_string())?; + /// + /// let compiled = engine.compile_with_entrypoint(&"data.authz.allow".into())?; + /// let result = compiled.eval_with_input(Value::from_json_str(r#"{"user": "alice", "action": "read"}"#)?)?; + /// assert_eq!(result, Value::from(true)); + /// # Ok(()) + /// # } + /// ``` + /// + /// ## Multi-Module Policy + /// + /// ```no_run + /// use regorus::*; + /// use std::rc::Rc; + /// + /// # fn main() -> anyhow::Result<()> { + /// let mut engine = Engine::new(); + /// engine.add_data(Value::from_json_str(r#"{"departments": {"engineering": ["alice"], "hr": ["bob"]}}"#)?)?; + /// + /// engine.add_policy("users.rego".to_string(), r#" + /// package users + /// import rego.v1 + /// + /// user_department(user) := dept if { + /// dept := [d | data.departments[d][_] == user][0] + /// } + /// "#.to_string())?; + /// + /// engine.add_policy("permissions.rego".to_string(), r#" + /// package permissions + /// import rego.v1 + /// import data.users + /// + /// default allow := false + /// allow if { + /// users.user_department(input.user) == "engineering" + /// input.resource.type == "code" + /// } + /// + /// allow if { + /// users.user_department(input.user) == "hr" + /// input.resource.type == "personnel_data" + /// } + /// "#.to_string())?; + /// + /// let compiled = engine.compile_with_entrypoint(&"data.permissions.allow".into())?; + /// + /// // Test engineering access to code + /// let result = compiled.eval_with_input(Value::from_json_str(r#" + /// { + /// "user": "alice", + /// "resource": {"type": "code", "name": "main.rs"} + /// }"#)?)?; + /// assert_eq!(result, Value::from(true)); + /// # Ok(()) + /// # } + /// ``` + /// + /// # Entry Point Rule Format + /// + /// The `rule` parameter should follow the Rego rule path format: + /// - `"data.package.rule"` - For rules in a specific package + /// - `"data.package.subpackage.rule"` - For nested packages + /// - `"allow"` - For rules in the default package (though this is not recommended) + /// + /// # Notes + /// + /// - Automatically enables print gathering for debugging purposes + /// - If you need target-aware compilation with automatic `__target__` handling, + /// consider using [`Engine::compile_for_target`] instead (requires `azure_policy` feature) + /// + /// # See Also + /// + /// - [`Engine::compile_for_target`] for target-aware compilation + /// - [`crate::compile_policy_with_entrypoint`] for a higher-level convenience function + pub fn compile_with_entrypoint(&mut self, rule: &Rc) -> Result { + self.prepare_for_eval(false, false)?; + self.interpreter.clean_internal_evaluation_state(); + self.interpreter + .compile(Some(rule.clone())) + .map(CompiledPolicy::new) + } + /// Evaluate specified rule(s). /// /// [`Engine::eval_rule`] is often faster than [`Engine::eval_query`] and should be preferred if @@ -438,7 +692,7 @@ impl Engine { /// # } /// ``` pub fn eval_rule(&mut self, rule: String) -> Result { - self.prepare_for_eval(false)?; + self.prepare_for_eval(false, false)?; self.interpreter.clean_internal_evaluation_state(); self.interpreter.eval_rule_in_path(rule) } @@ -479,7 +733,7 @@ impl Engine { /// # } /// ``` pub fn eval_query(&mut self, query: String, enable_tracing: bool) -> Result { - self.prepare_for_eval(enable_tracing)?; + self.prepare_for_eval(enable_tracing, false)?; self.interpreter.clean_internal_evaluation_state(); self.interpreter.create_rule_prefixes()?; @@ -622,7 +876,7 @@ impl Engine { } #[doc(hidden)] - fn prepare_for_eval(&mut self, enable_tracing: bool) -> Result<()> { + fn prepare_for_eval(&mut self, enable_tracing: bool, for_target: bool) -> Result<()> { self.interpreter.set_traces(enable_tracing); // if the data/policies have changed or the interpreter has never been prepared @@ -644,6 +898,23 @@ impl Engine { .set_functions(gather_functions(&self.modules)?); self.interpreter.gather_rules()?; self.interpreter.process_imports()?; + + #[cfg(feature = "azure_policy")] + if for_target { + // Resolve and validate target specifications across all modules + crate::interpreter::target::resolve::resolve_and_apply_target( + &mut self.interpreter, + )?; + // Infer resource types + crate::interpreter::target::infer::infer_resource_type(&mut self.interpreter)?; + } + + if !for_target { + // Check if any module specifies a target and warn if so + #[cfg(feature = "azure_policy")] + self.warn_if_targets_present(); + } + self.prepared = true; } @@ -657,7 +928,7 @@ impl Engine { rule: &Ref, enable_tracing: bool, ) -> Result { - self.prepare_for_eval(enable_tracing)?; + self.prepare_for_eval(enable_tracing, false)?; self.interpreter.clean_internal_evaluation_state(); self.interpreter.eval_rule(module, rule)?; @@ -667,7 +938,7 @@ impl Engine { #[doc(hidden)] pub fn eval_modules(&mut self, enable_tracing: bool) -> Result { - self.prepare_for_eval(enable_tracing)?; + self.prepare_for_eval(enable_tracing, false)?; self.interpreter.clean_internal_evaluation_state(); // Ensure that empty modules are created. @@ -1043,6 +1314,29 @@ impl Engine { Ok(policy_parameter_definitions) } + /// Emit a warning if any modules contain target specifications but we're not using target-aware compilation. + #[cfg(feature = "azure_policy")] + fn warn_if_targets_present(&self) { + let mut has_target = false; + let mut target_files = Vec::new(); + + for module in self.modules.iter() { + if module.target.is_some() { + has_target = true; + target_files.push(module.package.span.source.get_path()); + } + } + + if has_target { + std::eprintln!("Warning: Target specifications found in policy modules but not using target-aware compilation."); + std::eprintln!(" The following files contain __target__ declarations:"); + for file in target_files { + std::eprintln!(" - {}", file); + } + std::eprintln!(" Consider using compile_for_target() instead of compile_with_entrypoint() for target-aware evaluation."); + } + } + fn make_parser<'a>(&self, source: &'a Source) -> Result> { let mut parser = Parser::new(source)?; if self.rego_v1 { @@ -1050,4 +1344,18 @@ impl Engine { } Ok(parser) } + + /// Create a new Engine from a compiled policy. + #[doc(hidden)] + pub(crate) fn new_from_compiled_policy( + compiled_policy: Rc, + ) -> Self { + let modules = compiled_policy.modules.clone(); + Self { + modules, + interpreter: Interpreter::new_from_compiled_policy(compiled_policy), + rego_v1: true, // Value doesn't matter since this is used only for policy parsing + prepared: true, + } + } } diff --git a/src/interpreter.rs b/src/interpreter.rs index d9a1ed97..74fe0757 100644 --- a/src/interpreter.rs +++ b/src/interpreter.rs @@ -3,6 +3,9 @@ use crate::ast::*; use crate::builtins::{self, BuiltinFcn}; +use crate::compiled_policy::CompiledPolicyData; +#[cfg(feature = "azure_policy")] +use crate::compiled_policy::TargetInfo; use crate::lexer::*; use crate::parser::Parser; use crate::scheduler::*; @@ -18,7 +21,14 @@ use core::ops::Bound::*; type Scope = BTreeMap; -type DefaultRuleInfo = (Ref, Option); +#[cfg(feature = "azure_policy")] +pub mod error; +#[cfg(feature = "azure_policy")] +pub mod target { + pub mod infer; + pub mod resolve; +} + type ContextExprs = (Option>, Option>); type State = ( Value, @@ -36,22 +46,11 @@ enum FunctionModifier { Value(Value), } -#[derive(Debug, Clone, Default)] -pub struct CompiledPolicy { - modules: Rc>>, - schedule: Option, - rules: Map>>, - default_rules: Map>, - imports: BTreeMap>, - functions: FunctionTable, - rule_paths: Set, -} - type RuleValues = BTreeMap, (Value, Ref)>; #[derive(Debug)] pub struct Interpreter { - compiled_policy: Rc, + compiled_policy: Rc, data: Value, @@ -61,7 +60,6 @@ pub struct Interpreter { enable_coverage: bool, traces: Option>>, - strict_builtin_errors: bool, gather_prints: bool, prints: Vec, @@ -107,7 +105,6 @@ impl Clone for Interpreter { gather_prints: self.gather_prints, prints: self.prints.clone(), - strict_builtin_errors: self.strict_builtin_errors, traces: self.traces.clone(), extensions: self.extensions.clone(), @@ -216,8 +213,12 @@ impl LoopExpr { impl Interpreter { pub fn new() -> Interpreter { + let compiled_policy = compiled_policy::CompiledPolicyData { + strict_builtin_errors: true, // Preserve current behavior + ..Default::default() + }; Interpreter { - compiled_policy: Rc::new(CompiledPolicy::default()), + compiled_policy: Rc::new(compiled_policy), data: Value::new_object(), module: None, @@ -239,7 +240,6 @@ impl Interpreter { builtins_cache: BTreeMap::new(), no_rules_lookup: false, traces: None, - strict_builtin_errors: true, extensions: Map::new(), #[cfg(feature = "coverage")] @@ -252,7 +252,21 @@ impl Interpreter { } } - fn compiled_policy_mut(&mut self) -> &mut CompiledPolicy { + /// Create a new Interpreter from a compiled policy. + pub fn new_from_compiled_policy(compiled_policy: Rc) -> Self { + let mut interpreter = Self::new(); + interpreter.extensions = compiled_policy.extensions.clone(); + interpreter.compiled_policy = compiled_policy; + + // Set initial data if available + if let Some(data) = &interpreter.compiled_policy.data { + interpreter.init_data = data.clone(); + } + + interpreter + } + + fn compiled_policy_mut(&mut self) -> &mut CompiledPolicyData { Rc::make_mut(&mut self.compiled_policy) } @@ -284,6 +298,12 @@ impl Interpreter { &mut self.init_data } + // Used by tests. + #[allow(dead_code)] + pub fn get_compiled_policy(&self) -> &Rc { + &self.compiled_policy + } + pub fn set_traces(&mut self, enable_tracing: bool) { self.traces = match enable_tracing { true => Some(vec![]), @@ -292,7 +312,7 @@ impl Interpreter { } pub fn set_strict_builtin_errors(&mut self, b: bool) { - self.strict_builtin_errors = b; + self.compiled_policy_mut().strict_builtin_errors = b; } pub fn set_input(&mut self, input: Value) { @@ -649,7 +669,7 @@ impl Interpreter { rhs, lhs_value, rhs_value, - self.strict_builtin_errors, + self.compiled_policy.strict_builtin_errors, ), } } @@ -2233,10 +2253,15 @@ impl Interpreter { } } - let v = match builtin.0(span, params, &args[..], self.strict_builtin_errors) { + let v = match builtin.0( + span, + params, + &args[..], + self.compiled_policy.strict_builtin_errors, + ) { Ok(v) => v, // Ignore errors if we are not evaluating in strict mode. - Err(_) if !self.strict_builtin_errors => return Ok(Value::Undefined), + Err(_) if !self.compiled_policy.strict_builtin_errors => return Ok(Value::Undefined), Err(e) => Err(e)?, }; @@ -2550,7 +2575,7 @@ impl Interpreter { } } - if self.strict_builtin_errors && !errors.is_empty() { + if self.compiled_policy.strict_builtin_errors && !errors.is_empty() { return Err(anyhow!(errors[0].to_string())); } @@ -2947,7 +2972,7 @@ impl Interpreter { uexpr, Value::from(0), self.eval_expr(uexpr)?, - self.strict_builtin_errors, + self.compiled_policy.strict_builtin_errors, ) } _ => bail!(expr @@ -3658,9 +3683,7 @@ impl Interpreter { for c in 0..comps.len() { let path = self.current_module_path.clone() + "." + &comps[0..c + 1].join("."); if c + 1 == comps.len() { - Rc::make_mut(&mut self.compiled_policy) - .rule_paths - .insert(path.clone()); + self.compiled_policy_mut().rule_paths.insert(path.clone()); } match self.compiled_policy_mut().rules.entry(path) { @@ -3687,9 +3710,7 @@ impl Interpreter { for (idx, c) in (0..comps.len()).enumerate() { let path = self.current_module_path.clone() + "." + &comps[0..c + 1].join("."); if c + 1 == comps.len() { - Rc::make_mut(&mut self.compiled_policy) - .rule_paths - .insert(path.clone()); + self.compiled_policy_mut().rule_paths.insert(path.clone()); } match self.compiled_policy_mut().default_rules.entry(path) { @@ -3957,6 +3978,35 @@ impl Interpreter { self.ensure_rule_evaluated(path.clone())?; let parts: Vec<&str> = path.split('.').collect(); - Ok(Self::get_value_chained(self.data.clone(), &parts[1..])) + let value = Self::get_value_chained(self.data.clone(), &parts[1..]); + #[cfg(feature = "azure_policy")] + { + if let Some(target_info) = &self.compiled_policy.target_info { + // Allow undefined values to pass through without schema validation + if value != Value::Undefined { + target_info.effect_schema.validate(&value)?; + } + } + } + Ok(value) + } + + pub fn compile(&mut self, rule: Option>) -> Result> { + let data = Some(self.init_data.clone()); + let extensions = self.extensions.clone(); + let compiled_policy = self.compiled_policy_mut(); + + compiled_policy.data = data; + compiled_policy.extensions = extensions; + if let Some(rule) = rule { + if !compiled_policy.rule_paths.contains(rule.as_ref()) { + bail!("not a valid rule path"); + } + compiled_policy.rule_to_evaluate = rule; + } else { + compiled_policy.rule_to_evaluate = "".into(); + } + + Ok(self.compiled_policy.clone()) } } diff --git a/src/interpreter/error.rs b/src/interpreter/error.rs new file mode 100644 index 00000000..abeef90c --- /dev/null +++ b/src/interpreter/error.rs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::Rc; +use thiserror::Error; + +type String = Rc; + +/// Error type for interpreter target resolution operations. +#[derive(Debug, Clone, Error)] +pub enum TargetCompileError { + /// Multiple different targets specified across modules + #[error("Multiple different targets specified: '{existing}' and '{conflicting}'")] + ConflictingTargets { + existing: String, + conflicting: String, + }, + /// Target not found in registry + #[error("Target '{0}' not found in registry")] + TargetNotFound(String), + /// No target specified when one is required + #[error("No target specified. When using compile_for_target, at least one module must specify a target using the __target__ annotation")] + NoTargetSpecified, + /// Modules with targets have different packages + #[error("Modules with target '{target}' have different packages: '{existing_package}' and '{conflicting_package}'")] + ConflictingPackages { + target: String, + existing_package: String, + conflicting_package: String, + }, + + /// No effects have rules defined for the target + #[error( + "Target '{target_name}' requires a rule with name {effect_names} in package '{package}'" + )] + NoEffectRules { + target_name: String, + package: String, + effect_names: String, + }, + /// Multiple effect rules found for the same effect + #[error("Multiple effects have rules defined for target '{target_name}': {effect_names}. Only one effect should have rules defined in package '{path}'")] + MultipleEffectRules { + target_name: String, + effect_names: String, + path: String, + }, + + /// Missing default resource schema error + #[error("Missing default resource schema: {0}")] + MissingDefaultResourceSchema(String), + /// Incompatible default schema error + #[error("Incompatible default schema: {0}")] + IncompatibleDefaultSchema(String), + /// Invalid default schema type error + #[error("Invalid default schema type: {0}")] + InvalidDefaultSchemaType(String), +} diff --git a/src/interpreter/target/infer.rs b/src/interpreter/target/infer.rs new file mode 100644 index 00000000..a135ea2a --- /dev/null +++ b/src/interpreter/target/infer.rs @@ -0,0 +1,278 @@ +use super::super::error::TargetCompileError; +use super::super::*; +use crate::ast::{BoolOp, Expr, Literal, Query, Rule}; +use crate::compiled_policy::InferredResourceTypes; +use crate::value::Value; +use crate::{Rc, Schema}; + +type String = Rc; + +/// Analyzes policy rules to infer resource types from equality expressions. +/// +/// This function examines the compiled policy rules corresponding to the effect path +/// and searches for equality statements that compare the resource selector field with +/// string literals. It identifies patterns like: +/// - `input. == "resource_type_name"` +/// - `input["resource_selector"] == "resource_type_name"` +/// - `"resource_type_name" == input.` +/// - `"resource_type_name" == input["resource_selector"]` +/// +/// The `resource_selector` is determined by the target's resource schema selector +/// configuration (e.g., "type", "@odata.type"). +/// +/// # Schema Resolution +/// For each inferred resource type, the function attempts to find the corresponding +/// schema from the target's resource_schema_lookup table. If no specific schema is +/// found, it falls back to the default_resource_schema after validating compatibility. +/// +/// # Returns +/// An InferredResourceTypes map mapping Query references to ResourceTypeInfo tuples +/// containing (resource_type_name, schema). +/// The results are also stored in the compiled policy's inferred_resource_types field +/// for later use during policy evaluation. +/// +/// # Errors +/// Returns `TargetCompileError` if: +/// - Default resource schema is missing when needed +/// - Default schema is incompatible with the resource selector +/// - Default schema is not an object type +/// +/// # Examples +/// For a policy with rules like: +/// ```rego +/// effect := "allow" { input.type == "Microsoft.Storage/storageAccounts" } +/// effect := "deny" { input["@odata.type"] == "microsoft.graph.user" } +/// ``` +/// This function returns a map with entries for each query containing the resource type +/// conditions, mapping queries to their respective type names and schemas. +pub fn infer_resource_type( + interpreter: &mut Interpreter, +) -> Result { + // Check if we have target info + if let Some(ref target_info) = interpreter.compiled_policy.target_info { + let target = &target_info.target; + let effect_path = &target_info.effect_path; + let resource_selector = &target.resource_schema_selector; + + let mut result = InferredResourceTypes::new(); + + // Get rules for the effect path + if let Some(rules) = interpreter.compiled_policy.rules.get(effect_path.as_ref()) { + for rule in rules { + analyze_rule_for_resource_types(rule, resource_selector, target, &mut result)?; + } + } + + // Note: We don't check default_rules because default rules cannot access input + + // Store the result in the compiled policy for later use + let compiled_policy = Rc::make_mut(&mut interpreter.compiled_policy); + compiled_policy.inferred_resource_types = Some(result.clone()); + + Ok(result) + } else { + // No target info available + Ok(InferredResourceTypes::new()) + } +} + +fn analyze_rule_for_resource_types( + rule: &Rule, + resource_selector: &str, + target: &crate::target::Target, + result: &mut InferredResourceTypes, +) -> Result<(), TargetCompileError> { + if let Rule::Spec { bodies, .. } = rule { + for body in bodies { + analyze_query_for_resource_types(&body.query, resource_selector, target, result)?; + } + } + // Default rules typically don't contain resource type conditions + Ok(()) +} + +fn analyze_query_for_resource_types( + query: &Ref, + resource_selector: &str, + target: &crate::target::Target, + result: &mut InferredResourceTypes, +) -> Result<(), TargetCompileError> { + let mut found_resource_type: Option = None; + + for stmt in &query.stmts { + if let Literal::Expr { expr, .. } = &stmt.literal { + if let Some(resource_type) = analyze_expr_for_resource_types(expr, resource_selector) { + found_resource_type = Some(resource_type); + break; // Found resource type, no need to continue searching + } + } + // Note: We don't analyze NotExpr because it contains the opposite of type equality + // (e.g., not input.type == "value" means the type is NOT that value) + // Other literal statement (SomeVars, SomeIn, Every) don't typically contain + // direct resource type comparisons + } + + // Now handle the insertion outside the loop + if let Some(resource_type) = found_resource_type { + // Look up the schema for this resource type + let resource_type_value = Value::String(resource_type.clone()); + if let Some(schema) = target.resource_schema_lookup.get(&resource_type_value) { + result.insert(query.clone(), (resource_type, schema.clone())); + return Ok(()); + } + // If not found in lookup, use default schema + let default_schema = get_validated_default_schema(target, resource_selector)?; + result.insert(query.clone(), (resource_type, default_schema)); + } else { + // If no resource type was found for this query, use default schema + let default_schema = get_validated_default_schema(target, resource_selector)?; + result.insert(query.clone(), ("".into(), default_schema)); + } + + Ok(()) +} + +fn analyze_expr_for_resource_types(expr: &Expr, resource_selector: &str) -> Option { + // Only look for direct equality expressions: input. == "string" + if let Expr::BoolExpr { + op: BoolOp::Eq, + lhs, + rhs, + .. + } = expr + { + // Check if this is input. == "string" + if let (Some(input_field), Some(string_value)) = ( + extract_input_field_access(lhs, resource_selector), + extract_string_literal(rhs), + ) { + if input_field.as_ref() == resource_selector { + return Some(string_value); + } + } + // Also check the reverse: "string" == input. + else if let (Some(string_value), Some(input_field)) = ( + extract_string_literal(lhs), + extract_input_field_access(rhs, resource_selector), + ) { + if input_field.as_ref() == resource_selector { + return Some(string_value); + } + } + } + // We only look for direct equality expressions, no nested analysis + None +} + +/// Extract input field access like `input.type` or `input["@odata.type"]` +fn extract_input_field_access(expr: &Expr, _expected_field: &str) -> Option { + use crate::value::Value; + + match expr { + // Handle input.field + Expr::RefDot { refr, field, .. } => { + if let ( + Expr::Var { + value: Value::String(var_name), + .. + }, + Value::String(field_name), + ) = (refr.as_ref(), &field.1) + { + if var_name.as_ref() == "input" { + return Some(field_name.clone()); + } + } + } + // Handle input["field"] - the field is always a string literal + Expr::RefBrack { refr, index, .. } => { + if let ( + Expr::Var { + value: Value::String(var_name), + .. + }, + Some(field_name), + ) = (refr.as_ref(), extract_string_literal(index)) + { + if var_name.as_ref() == "input" { + return Some(field_name); + } + } + } + _ => {} + } + None +} + +/// Extract string literal from expression +fn extract_string_literal(expr: &Expr) -> Option { + use crate::value::Value; + + if let Expr::String { + value: Value::String(s), + .. + } = expr + { + Some(s.clone()) + } else { + None + } +} + +/// Get and validate the default resource schema. +/// Returns the default schema if it exists and is compatible with the resource selector. +fn get_validated_default_schema( + target: &crate::target::Target, + resource_selector: &str, +) -> Result, TargetCompileError> { + if let Some(default_schema) = &target.default_resource_schema { + // Validate that default schema can handle the resource selector field + validate_default_schema_compatibility(default_schema, resource_selector)?; + Ok(default_schema.clone()) + } else { + Err(TargetCompileError::MissingDefaultResourceSchema( + format!("Target '{}' has no default resource schema", target.name).into(), + )) + } +} + +/// Validate that the default schema is compatible with the resource selector field. +/// The schema must either allow additional properties or have a property matching the resource selector. +fn validate_default_schema_compatibility( + schema: &Rc, + resource_selector: &str, +) -> Result<(), TargetCompileError> { + use crate::schema::Type; + + match schema.as_type() { + Type::Object { + properties, + additional_properties, + .. + } => { + // Check if the schema has a property matching the resource selector + if properties.contains_key(resource_selector) { + return Ok(()); + } + + // Check if additional properties are allowed + if additional_properties.is_some() { + return Ok(()); + } + + // Neither condition is met + Err(TargetCompileError::IncompatibleDefaultSchema( + format!( + "Default resource schema must either have additional properties enabled or contain a '{}' property", + resource_selector + ).into() + )) + } + _ => { + // Default schema is not an object type + Err(TargetCompileError::InvalidDefaultSchemaType( + "Default resource schema must be an object type".into(), + )) + } + } +} diff --git a/src/interpreter/target/resolve.rs b/src/interpreter/target/resolve.rs new file mode 100644 index 00000000..7389e0dd --- /dev/null +++ b/src/interpreter/target/resolve.rs @@ -0,0 +1,248 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::super::error::TargetCompileError; +#[cfg(feature = "azure_policy")] +use super::super::TargetInfo; +use super::super::*; + +fn format_effect_names(names: &[String]) -> String { + match names.len() { + 0 => String::new(), + 1 => names[0].clone(), + 2 => format!("{} or {}", names[0], names[1]), + _ => { + if let Some((last, rest)) = names.split_last() { + format!("{} or {}", rest.join(", "), last) + } else { + String::new() + } + } + } +} + +pub fn resolve_target(interpreter: &mut Interpreter) -> Result<(), TargetCompileError> { + use crate::registry::targets; + + let mut target_name: Option = None; + let mut target_package: Option = None; + + // Check all modules for target specifications + for module in interpreter.compiled_policy.modules.iter() { + if let Some(ref module_target) = module.target { + // Get the package path for this module + let module_package = Interpreter::get_path_string(&module.package.refr, None) + .map_err(|_| TargetCompileError::TargetNotFound(module_target.clone().into()))?; + + match &target_name { + None => { + // First target found + target_name = Some(module_target.clone()); + target_package = Some(module_package); + } + Some(existing_target) => { + // Ensure all modules specify the same target + if existing_target != module_target { + return Err(TargetCompileError::ConflictingTargets { + existing: existing_target.as_str().into(), + conflicting: module_target.as_str().into(), + }); + } + + // Ensure all modules with targets have the same package + if let Some(ref existing_package) = target_package { + if existing_package != &module_package { + return Err(TargetCompileError::ConflictingPackages { + target: module_target.as_str().into(), + existing_package: existing_package.as_str().into(), + conflicting_package: module_package.as_str().into(), + }); + } + } + } + } + } + } + + // If a target is specified, retrieve it from the registry + if let Some(target_name) = target_name { + match targets::get(&target_name) { + Some(target) => { + // Target found in registry - store it in the compiled policy + // We'll set a default effect schema here, but it will be updated in resolve_effect + // once we determine which effect actually has rules defined + let default_effect_schema = match target.effects.values().next() { + Some(schema) => schema.clone(), + None => { + return Err(TargetCompileError::TargetNotFound( + format!("Target '{}' has no effects defined", target_name) + .as_str() + .into(), + )); + } + }; + let target_info = TargetInfo { + target, + package: match target_package { + Some(pkg) => pkg.as_str().into(), + None => { + return Err(TargetCompileError::TargetNotFound( + format!("No package found for target '{}'", target_name) + .as_str() + .into(), + )); + } + }, + effect_schema: default_effect_schema, + effect_name: "".into(), // Will be updated in resolve_effect + effect_path: "".into(), // Will be updated in resolve_effect + }; + interpreter.compiled_policy_mut().target_info = Some(target_info); + } + None => { + return Err(TargetCompileError::TargetNotFound( + target_name.as_str().into(), + )); + } + } + } else { + // No target specified - this is an error when using compile_for_target + return Err(TargetCompileError::NoTargetSpecified); + } + + Ok(()) +} + +pub fn resolve_effect(interpreter: &mut Interpreter) -> Result<(), TargetCompileError> { + // Check if we have target info from resolve_target + if let Some(ref target_info) = interpreter.compiled_policy.target_info { + let target = &target_info.target; + let package = &target_info.package; + + let mut effects_with_rules = Vec::new(); + + // For each effect defined in the target, check if rules exist + for effect_name in target.effects.keys() { + // Rule keys are stored with "data." prefix in CompiledPolicy + let expected_path = format!("data.{}.{}", package, effect_name); + + // Disallow sub-paths for effects in rules. + for rule_path in interpreter.compiled_policy.rules.keys() { + if rule_path.starts_with(&expected_path) && rule_path.len() > expected_path.len() { + // Sub-paths are not allowed for effects - they must be exact matches only + // This prevents effect rules from being defined at deeper nested paths + let all_effect_names: Vec = + target.effects.keys().map(|k| k.to_string()).collect(); + let formatted_names = format_effect_names(&all_effect_names); + return Err(TargetCompileError::NoEffectRules { + target_name: target.name.to_string().into(), + package: package.to_string().into(), + effect_names: formatted_names.as_str().into(), + }); + } + } + + // Disallow sub-paths for effects in default_rules. + for rule_path in interpreter.compiled_policy.default_rules.keys() { + if rule_path.starts_with(&expected_path) && rule_path.len() > expected_path.len() { + // Sub-paths are not allowed for effects - they must be exact matches only + let all_effect_names: Vec = + target.effects.keys().map(|k| k.to_string()).collect(); + let formatted_names = format_effect_names(&all_effect_names); + return Err(TargetCompileError::NoEffectRules { + target_name: target.name.to_string().into(), + package: package.to_string().into(), + effect_names: formatted_names.as_str().into(), + }); + } + } + + // Check if rules exist at the expected path or any sub-path + let mut has_rules = false; + + // Check for exact match in rules + if let Some(rules) = interpreter.compiled_policy.rules.get(&expected_path) { + if !rules.is_empty() { + has_rules = true; + } + } + + // Check for exact match in default_rules + if !has_rules { + if let Some(default_rules) = interpreter + .compiled_policy + .default_rules + .get(&expected_path) + { + if !default_rules.is_empty() { + has_rules = true; + } + } + } + + if has_rules { + effects_with_rules.push(effect_name.clone()); + } + } + + // Ensure exactly one effect has rules defined + match effects_with_rules.len() { + 0 => { + let all_effect_names: Vec = + target.effects.keys().map(|k| k.to_string()).collect(); + let formatted_names = format_effect_names(&all_effect_names); + return Err(TargetCompileError::NoEffectRules { + target_name: target.name.to_string().into(), + package: package.to_string().into(), + effect_names: formatted_names.as_str().into(), + }); + } + 1 => { + // Exactly one effect has rules - this is correct + // Update the target info with the correct effect schema + let effect_name = &effects_with_rules[0]; + let effect_schema = match target.effects.get(effect_name) { + Some(schema) => schema.clone(), + None => { + // This should not happen since we got the effect_name from target.effects.keys() + return Err(TargetCompileError::TargetNotFound( + format!( + "Effect '{}' not found in target '{}'", + effect_name, target.name + ) + .as_str() + .into(), + )); + } + }; + + // Update the target info with the correct effect schema, name, and path + let expected_path = format!("data.{}.{}", package, effect_name); + if let Some(ref mut target_info) = interpreter.compiled_policy_mut().target_info { + target_info.effect_schema = effect_schema; + target_info.effect_name = effect_name.as_ref().into(); + target_info.effect_path = expected_path.as_str().into(); + } + } + _ => { + return Err(TargetCompileError::MultipleEffectRules { + target_name: target.name.to_string().into(), + effect_names: effects_with_rules.join(", ").as_str().into(), + path: package.to_string().into(), + }); + } + } + } + + Ok(()) +} + +pub fn resolve_and_apply_target(interpreter: &mut Interpreter) -> Result<(), TargetCompileError> { + // Resolve the target first + resolve_target(interpreter)?; + + // Then resolve the effect + resolve_effect(interpreter)?; + + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 996a4937..0cf91b94 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,31 +21,44 @@ extern crate std; mod ast; mod builtins; +mod compile; +mod compiled_policy; mod engine; mod indexchecker; mod interpreter; mod lexer; mod number; mod parser; +mod policy_info; #[cfg(feature = "azure_policy")] -mod registry; +pub mod registry; mod scheduler; #[cfg(feature = "azure_policy")] mod schema; +#[cfg(feature = "azure_policy")] +pub mod target; mod utils; mod value; +#[cfg(feature = "azure_policy")] +pub use { + compile::compile_policy_for_target, + schema::{error::ValidationError, validate::SchemaValidator, Schema}, + target::Target, +}; + +pub use compile::{compile_policy_with_entrypoint, PolicyModule}; +pub use compiled_policy::CompiledPolicy; pub use engine::Engine; pub use lexer::Source; -#[cfg(feature = "azure_policy")] -pub use schema::{error::ValidationError, validate::SchemaValidator, Schema}; +pub use policy_info::PolicyInfo; pub use value::Value; #[cfg(feature = "arc")] -use alloc::sync::Arc as Rc; +pub use alloc::sync::Arc as Rc; #[cfg(not(feature = "arc"))] -use alloc::rc::Rc; +pub use alloc::rc::Rc; #[cfg(feature = "std")] use std::collections::{hash_map::Entry as MapEntry, HashMap as Map, HashSet as Set}; diff --git a/src/parser.rs b/src/parser.rs index 4ae63124..c46e3ac8 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -1881,18 +1881,75 @@ impl<'source> Parser<'source> { Ok(imports) } + fn parse_string_literal(&mut self) -> Result { + if self.tok.0 != TokenKind::String { + bail!(self.tok.1.error("expected string literal")); + } + + let string_span = self.tok.1.clone(); + let target_value = + match serde_json::from_str::(format!("\"{}\"", string_span.text()).as_str()) { + Ok(v) => v, + Err(e) => { + bail!(string_span.error(&format!("invalid string literal: {}", e))); + } + }; + + self.next_token()?; + + match target_value.as_string() { + Ok(s) => Ok(s.as_ref().to_string()), + Err(_) => { + bail!(string_span.error("invalid string value")); + } + } + } + + fn parse_target_rule(&mut self) -> Result> { + // Check if the current token starts a target rule: __target__ + if self.tok.0 == TokenKind::Ident && self.token_text() == "__target__" { + // Parse __target__ + self.next_token()?; + + // Expect := operator + if self.token_text() != ":=" { + bail!(self.tok.1.error("expected ':=' after __target__")); + } + self.next_token()?; + + // Parse the target name string using the helper function + let target_string = self.parse_string_literal()?; + + Ok(Some(target_string)) + } else { + Ok(None) + } + } + pub fn parse(&mut self) -> Result { let package = self.parse_package()?; let imports = self.parse_imports()?; + let target = self.parse_target_rule()?; + if target.is_some() { + self.rego_v1 = true; + } + let mut policy = vec![]; while self.tok.0 != TokenKind::Eof { policy.push(Ref::new(self.parse_rule()?)); + if self.token_text() == "__target__" { + bail!(self + .tok + .1 + .error("__target__ must be defined before any rules")); + } } let m = Module { package, imports, + target, policy, rego_v1: self.rego_v1, num_expressions: self.eidx, diff --git a/src/policy_info.rs b/src/policy_info.rs new file mode 100644 index 00000000..301307dc --- /dev/null +++ b/src/policy_info.rs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(feature = "azure_policy")] +use crate::engine::PolicyParameters; +use crate::*; +type String = Rc; + +/// Information about a compiled policy, including metadata about modules, +/// target configuration, and resource types that the policy can evaluate. +#[derive(serde::Serialize)] +pub struct PolicyInfo { + /// List of module identifiers that were compiled into this policy. + /// Each module ID represents a unique policy module that contributes + /// rules, functions, or data to the compiled policy. + pub module_ids: Vec, + + /// Name of the target configuration used during compilation, if any. + /// This indicates which target schema and validation rules were applied. + pub target_name: Option, + + /// List of resource types that this policy can evaluate. + /// For target-aware policies, this contains the inferred or configured + /// resource types. For general policies, this may be empty. + pub applicable_resource_types: Vec, + + /// The primary rule or entrypoint that this policy evaluates. + /// This is the rule path that will be executed when the policy runs. + pub entrypoint_rule: String, + + /// The effect rule name for target-aware policies, if applicable. + /// This is the specific effect rule (e.g., "effect", "allow", "deny") + /// that determines the policy decision for target evaluation. + pub effect_rule: Option, + + /// Parameters that can be configured for this policy. + /// Contains parameter names and their expected types or default values. + /// Used for parameterized policies that accept configuration at evaluation time. + /// Each element represents parameters from a different module. + #[cfg(feature = "azure_policy")] + pub parameters: Vec, +} diff --git a/src/registry.rs b/src/registry.rs index 3bb7aaf8..993890d6 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -13,6 +13,7 @@ mod tests { mod core; mod effect; mod resource; + mod target; } /// Errors that can occur when interacting with a Registry. @@ -166,6 +167,9 @@ impl Registry { /// Type alias for Schema registry pub type SchemaRegistry = Registry; +/// Type alias for Target registry +pub type TargetRegistry = Registry; + /// Global registry instances pub mod instances { use super::*; @@ -179,6 +183,11 @@ pub mod instances { /// Global singleton instance of effect schemas registry. pub static ref EFFECT_SCHEMA_REGISTRY: Registry = Registry::new("EFFECT_SCHEMA_REGISTRY"); } + + lazy_static::lazy_static! { + /// Global singleton instance of targets registry. + pub static ref TARGET_REGISTRY: Registry = Registry::new("TARGET_REGISTRY"); + } } /// Macro to generate helper functions for registry operations. @@ -287,3 +296,50 @@ pub mod schemas { "effect schemas" ); } + +/// Helper functions for target registry operations. +pub mod targets { + use super::*; + use instances::*; + + /// Register a target using its name property. + pub fn register(item: Rc) -> Result<(), RegistryError> { + let name = item.name.as_ref().to_string(); + TARGET_REGISTRY.register(name, item) + } + + /// Retrieve a target by name. + pub fn get(name: &str) -> Option> { + TARGET_REGISTRY.get(name) + } + + /// Remove a target by name. + pub fn remove(name: &str) -> Option> { + TARGET_REGISTRY.remove(name) + } + + /// List all registered target names. + pub fn list_names() -> Vec { + TARGET_REGISTRY.list_names() + } + + /// Check if a target with the given name exists. + pub fn contains(name: &str) -> bool { + TARGET_REGISTRY.contains(name) + } + + /// Get the number of registered targets. + pub fn len() -> usize { + TARGET_REGISTRY.len() + } + + /// Check if the target registry is empty. + pub fn is_empty() -> bool { + TARGET_REGISTRY.is_empty() + } + + /// Clear all targets from the registry. + pub fn clear() { + TARGET_REGISTRY.clear(); + } +} diff --git a/src/registry/tests/target.rs b/src/registry/tests/target.rs new file mode 100644 index 00000000..3024a2e4 --- /dev/null +++ b/src/registry/tests/target.rs @@ -0,0 +1,1254 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::super::registry::*; +use crate::{ + registry::{instances::TARGET_REGISTRY, targets}, + target::{Target, TargetError}, + *, +}; +use serde_json::json; + +type String = Rc; +type TargetRegistryError = RegistryError; + +// Helper function to create a basic test target +fn create_basic_target(name: &str) -> Rc { + let target_json = json!({ + "name": name, + "description": "A test target for validation", + "version": "1.0.0", + "resource_schema_selector": "type", + "resource_schemas": [ + { + "type": "object", + "properties": { + "name": { "type": "string" }, + "type": { "const": "user" } + }, + "required": ["name", "type"] + } + ], + "effects": { + "allow": { "type": "boolean" }, + "deny": { "type": "boolean" } + } + }); + + let target = Target::from_json_str(&target_json.to_string()).unwrap(); + Rc::new(target) +} + +// Helper function to create an Azure resource target +fn create_azure_target(name: &str) -> Rc { + let target_json = json!({ + "name": name, + "description": "Target for Azure resource policies", + "version": "2.0.0", + "resource_schema_selector": "type", + "resource_schemas": [ + { + "type": "object", + "properties": { + "type": { "const": "Microsoft.Compute/virtualMachines" }, + "name": { "type": "string" }, + "location": { "type": "string" }, + "properties": { + "type": "object", + "properties": { + "hardwareProfile": { + "type": "object", + "properties": { + "vmSize": { "type": "string" } + } + } + } + } + }, + "required": ["type", "name", "location"] + }, + { + "type": "object", + "properties": { + "type": { "const": "Microsoft.Storage/storageAccounts" }, + "name": { "type": "string" }, + "location": { "type": "string" }, + "sku": { + "type": "object", + "properties": { + "name": { "type": "string" } + } + } + }, + "required": ["type", "name", "location", "sku"] + } + ], + "effects": { + "allow": { "type": "boolean" }, + "deny": { "type": "boolean" }, + "audit": { "type": "string" } + } + }); + + let target = Target::from_json_str(&target_json.to_string()).unwrap(); + Rc::new(target) +} + +// Helper function to create a Kubernetes target +fn create_kubernetes_target(name: &str) -> Rc { + let target_json = json!({ + "name": name, + "description": "Target for Kubernetes resource policies", + "version": "1.2.0", + "resource_schema_selector": "kind", + "resource_schemas": [ + { + "type": "object", + "properties": { + "apiVersion": { "type": "string" }, + "kind": { "const": "Pod" }, + "metadata": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "namespace": { "type": "string" } + }, + "required": ["name"] + }, + "spec": { + "type": "object", + "properties": { + "containers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "image": { "type": "string" } + }, + "required": ["name", "image"] + } + } + }, + "required": ["containers"] + } + }, + "required": ["apiVersion", "kind", "metadata", "spec"] + }, + { + "type": "object", + "properties": { + "apiVersion": { "type": "string" }, + "kind": { "const": "Service" }, + "metadata": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "namespace": { "type": "string" } + }, + "required": ["name"] + }, + "spec": { + "type": "object", + "properties": { + "selector": { "type": "object" }, + "ports": { + "type": "array", + "items": { + "type": "object", + "properties": { + "port": { "type": "integer" }, + "targetPort": { "type": "integer" } + }, + "required": ["port"] + } + } + } + } + }, + "required": ["apiVersion", "kind", "metadata", "spec"] + } + ], + "effects": { + "allow": { "type": "boolean" }, + "deny": { "type": "boolean" }, + "warn": { "type": "string" } + } + }); + + let target = Target::from_json_str(&target_json.to_string()).unwrap(); + Rc::new(target) +} + +// Helper function to create a generic API target +fn create_api_target(name: &str) -> Rc { + let target_json = json!({ + "name": name, + "description": "Target for API Gateway policies", + "version": "3.1.0", + "resource_schema_selector": "operation", + "resource_schemas": [ + { + "type": "object", + "properties": { + "operation": { "const": "GET" }, + "path": { "type": "string" }, + "headers": { "type": "object" }, + "queryParams": { "type": "object" } + }, + "required": ["operation", "path"] + }, + { + "type": "object", + "properties": { + "operation": { "const": "POST" }, + "path": { "type": "string" }, + "headers": { "type": "object" }, + "body": { "type": "object" } + }, + "required": ["operation", "path", "body"] + } + ], + "effects": { + "allow": { "type": "boolean" }, + "deny": { "type": "boolean" }, + "rate_limit": { + "type": "object", + "properties": { + "requests_per_minute": { "type": "integer" } + } + } + } + }); + + let target = Target::from_json_str(&target_json.to_string()).unwrap(); + Rc::new(target) +} + +// Helper function to create a Microsoft Graph API target +fn create_msgraph_target(name: &str) -> Rc { + let target_json = json!({ + "name": name, + "description": "Target for Microsoft Graph API policies", + "version": "1.0.0", + "resource_schema_selector": "@odata.type", + "resource_schemas": [ + { + "type": "object", + "properties": { + "@odata.type": { "const": "#microsoft.graph.user" }, + "id": { "type": "string" }, + "userPrincipalName": { + "type": "string", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" + }, + "displayName": { "type": "string" }, + "givenName": { "type": "string" }, + "surname": { "type": "string" }, + "mail": { + "type": "string", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" + }, + "jobTitle": { "type": "string" }, + "department": { "type": "string" }, + "accountEnabled": { "type": "boolean" }, + "assignedLicenses": { + "type": "array", + "items": { + "type": "object", + "properties": { + "skuId": { "type": "string" }, + "disabledPlans": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + }, + "required": ["@odata.type", "id", "userPrincipalName"] + }, + { + "type": "object", + "properties": { + "@odata.type": { "const": "#microsoft.graph.group" }, + "id": { "type": "string" }, + "displayName": { "type": "string" }, + "description": { "type": "string" }, + "groupTypes": { + "type": "array", + "items": { + "enum": ["Unified", "DynamicMembership"] + } + }, + "mailEnabled": { "type": "boolean" }, + "securityEnabled": { "type": "boolean" }, + "mail": { "type": "string" }, + "visibility": { + "enum": ["Private", "Public", "HiddenMembership"] + }, + "members": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["@odata.type", "id", "displayName"] + }, + { + "type": "object", + "properties": { + "@odata.type": { "const": "#microsoft.graph.application" }, + "id": { "type": "string" }, + "appId": { "type": "string" }, + "displayName": { "type": "string" }, + "publisherDomain": { "type": "string" }, + "signInAudience": { + "enum": ["AzureADMyOrg", "AzureADMultipleOrgs", "AzureADandPersonalMicrosoftAccount", "PersonalMicrosoftAccount"] + }, + "requiredResourceAccess": { + "type": "array", + "items": { + "type": "object", + "properties": { + "resourceAppId": { "type": "string" }, + "resourceAccess": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "type": { + "enum": ["Scope", "Role"] + } + }, + "required": ["id", "type"] + } + } + }, + "required": ["resourceAppId", "resourceAccess"] + } + } + }, + "required": ["@odata.type", "id", "appId", "displayName"] + }, + { + "type": "object", + "properties": { + "@odata.type": { "const": "#microsoft.graph.device" }, + "id": { "type": "string" }, + "deviceId": { "type": "string" }, + "displayName": { "type": "string" }, + "operatingSystem": { "type": "string" }, + "operatingSystemVersion": { "type": "string" }, + "isCompliant": { "type": "boolean" }, + "isManaged": { "type": "boolean" }, + "trustType": { + "enum": ["Workplace", "AzureAd", "ServerAd"] + }, + "registrationDateTime": { + "type": "string" + } + }, + "required": ["@odata.type", "id", "deviceId", "displayName"] + } + ], + "effects": { + "allow": { "type": "boolean" }, + "deny": { "type": "boolean" }, + "audit": { + "type": "object", + "properties": { + "level": { + "enum": ["info", "warning", "error"] + }, + "message": { "type": "string" } + }, + "required": ["level", "message"] + }, + "conditional_access": { + "type": "object", + "properties": { + "requireMFA": { "type": "boolean" }, + "requireCompliantDevice": { "type": "boolean" }, + "allowedLocations": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + }); + + let target = Target::from_json_str(&target_json.to_string()).unwrap(); + Rc::new(target) +} + +// Helper function to create an Azure relationship policies target +fn create_azure_relationship_policies_target(name: &str) -> Rc { + let target_json = json!({ + "name": name, + "description": "Target for Azure resource relationship policies based on immutable and common properties", + "version": "1.0.0", + "resource_schema_selector": "type", + "resource_schemas": [ + { + "type": "object", + "properties": { + "type": { "type": "string" }, + "id": { "type": "string" }, + "name": { "type": "string" }, + "location": { "type": "string" }, + "resourceGroup": { "type": "string" }, + "subscriptionId": { "type": "string" }, + "tenantId": { "type": "string" }, + "tags": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "properties": { + "type": "object", + "properties": { + "provisioningState": { "type": "string" }, + "resourceGuid": { "type": "string" }, + "creationTime": { "type": "string" }, + "timeCreated": { "type": "string" } + } + }, + "managedBy": { "type": "string" }, + }, + "required": ["type", "id", "name", "location", "resourceGroup", "subscriptionId"], + "description": "Universal Azure resource schema for relationship policies based on common immutable properties" + } + ], + "effects": { + "manage": { + "anyOf": [ + { "type": "boolean" }, + { + "type": "object", + "properties": { + "target": { "type": "string" } + }, + "required": ["target"], + "additionalProperties": false + } + ] + } + } + }); + + let target = Target::from_json_str(&target_json.to_string()).unwrap(); + Rc::new(target) +} + +#[test] +fn test_basic_target_registration() { + let target = create_basic_target("basic.test.target"); + + // Test registration of basic target + let result = TARGET_REGISTRY.register("basic.test.target", target.clone()); + assert!(result.is_ok()); + assert!(TARGET_REGISTRY.contains("basic.test.target")); + + // Verify target can be retrieved + let retrieved = TARGET_REGISTRY.get("basic.test.target"); + assert!(retrieved.is_some()); + assert!(Rc::ptr_eq(&target, &retrieved.unwrap())); +} + +#[test] +fn test_azure_target_registration() { + let azure_target = create_azure_target("azure.compute.target"); + + // Test registration of Azure target + let result = TARGET_REGISTRY.register("azure.compute.target", azure_target.clone()); + assert!(result.is_ok()); + assert!(TARGET_REGISTRY.contains("azure.compute.target")); + + // Verify target structure + let retrieved = TARGET_REGISTRY.get("azure.compute.target"); + assert!(retrieved.is_some()); + let target = retrieved.unwrap(); + + assert_eq!(target.name.as_ref(), "azure.compute.target"); + assert_eq!(target.version.as_ref(), "2.0.0"); + assert_eq!(target.resource_schema_selector.as_ref(), "type"); + assert_eq!(target.resource_schemas.len(), 2); + assert_eq!(target.effects.len(), 3); + + // Verify lookup table was populated + assert_eq!(target.resource_schema_lookup.len(), 2); + assert!(target + .resource_schema_lookup + .contains_key(&Value::String("Microsoft.Compute/virtualMachines".into()))); + assert!(target + .resource_schema_lookup + .contains_key(&Value::String("Microsoft.Storage/storageAccounts".into()))); + + // Verify pointer equality + assert!(Rc::ptr_eq(&azure_target, &target)); +} + +#[test] +fn test_kubernetes_target_registration() { + let k8s_target = create_kubernetes_target("kubernetes.resources.target"); + + // Test registration of Kubernetes target + let result = TARGET_REGISTRY.register("kubernetes.resources.target", k8s_target.clone()); + assert!(result.is_ok()); + assert!(TARGET_REGISTRY.contains("kubernetes.resources.target")); + + // Verify target structure + let retrieved = TARGET_REGISTRY.get("kubernetes.resources.target"); + assert!(retrieved.is_some()); + let target = retrieved.unwrap(); + + assert_eq!(target.name.as_ref(), "kubernetes.resources.target"); + assert_eq!(target.version.as_ref(), "1.2.0"); + assert_eq!(target.resource_schema_selector.as_ref(), "kind"); + assert_eq!(target.resource_schemas.len(), 2); + assert_eq!(target.effects.len(), 3); + + // Verify lookup table was populated for Kubernetes kinds + assert_eq!(target.resource_schema_lookup.len(), 2); + assert!(target + .resource_schema_lookup + .contains_key(&Value::String("Pod".into()))); + assert!(target + .resource_schema_lookup + .contains_key(&Value::String("Service".into()))); + + // Verify pointer equality + assert!(Rc::ptr_eq(&k8s_target, &target)); +} + +#[test] +fn test_api_target_registration() { + let api_target = create_api_target("api.gateway.target"); + + // Test registration of API target + let result = TARGET_REGISTRY.register("api.gateway.target", api_target.clone()); + assert!(result.is_ok()); + assert!(TARGET_REGISTRY.contains("api.gateway.target")); + + // Verify target structure + let retrieved = TARGET_REGISTRY.get("api.gateway.target"); + assert!(retrieved.is_some()); + let target = retrieved.unwrap(); + + assert_eq!(target.name.as_ref(), "api.gateway.target"); + assert_eq!(target.version.as_ref(), "3.1.0"); + assert_eq!(target.resource_schema_selector.as_ref(), "operation"); + assert_eq!(target.resource_schemas.len(), 2); + assert_eq!(target.effects.len(), 3); + + // Verify lookup table was populated for HTTP operations + assert_eq!(target.resource_schema_lookup.len(), 2); + assert!(target + .resource_schema_lookup + .contains_key(&Value::String("GET".into()))); + assert!(target + .resource_schema_lookup + .contains_key(&Value::String("POST".into()))); + + // Verify pointer equality + assert!(Rc::ptr_eq(&api_target, &target)); +} + +#[test] +fn test_multiple_target_registration() { + // Register multiple targets with unique names + let basic_name = "multi.basic.target"; + let azure_name = "multi.azure.target"; + let k8s_name = "multi.kubernetes.target"; + let api_name = "multi.api.target"; + + let basic_target = create_basic_target(basic_name); + let azure_target = create_azure_target(azure_name); + let k8s_target = create_kubernetes_target(k8s_name); + let api_target = create_api_target(api_name); + + assert!(TARGET_REGISTRY.register(basic_name, basic_target).is_ok()); + assert!(TARGET_REGISTRY.register(azure_name, azure_target).is_ok()); + assert!(TARGET_REGISTRY.register(k8s_name, k8s_target).is_ok()); + assert!(TARGET_REGISTRY.register(api_name, api_target).is_ok()); + + // Verify all are registered + assert!(TARGET_REGISTRY.contains(basic_name)); + assert!(TARGET_REGISTRY.contains(azure_name)); + assert!(TARGET_REGISTRY.contains(k8s_name)); + assert!(TARGET_REGISTRY.contains(api_name)); + + // Verify they can all be retrieved + assert!(TARGET_REGISTRY.get(basic_name).is_some()); + assert!(TARGET_REGISTRY.get(azure_name).is_some()); + assert!(TARGET_REGISTRY.get(k8s_name).is_some()); + assert!(TARGET_REGISTRY.get(api_name).is_some()); +} + +#[test] +fn test_global_target_registry() { + // Register targets using global helper functions + let basic_name = "global.basic.target"; + let azure_name = "global.azure.target"; + let k8s_name = "global.kubernetes.target"; + let api_name = "global.api.target"; + + let basic_target = create_basic_target(basic_name); + let azure_target = create_azure_target(azure_name); + let k8s_target = create_kubernetes_target(k8s_name); + let api_target = create_api_target(api_name); + + assert!(targets::register(basic_target.clone()).is_ok()); + assert!(targets::register(azure_target.clone()).is_ok()); + assert!(targets::register(k8s_target.clone()).is_ok()); + assert!(targets::register(api_target.clone()).is_ok()); + + // Verify all are registered in global registry + assert!(targets::contains(basic_name)); + assert!(targets::contains(azure_name)); + assert!(targets::contains(k8s_name)); + assert!(targets::contains(api_name)); + + // Test retrieval from global registry + let retrieved_basic = targets::get(basic_name); + let retrieved_azure = targets::get(azure_name); + let retrieved_k8s = targets::get(k8s_name); + let retrieved_api = targets::get(api_name); + + assert!(retrieved_basic.is_some()); + assert!(retrieved_azure.is_some()); + assert!(retrieved_k8s.is_some()); + assert!(retrieved_api.is_some()); + + // Verify pointer equality + assert!(Rc::ptr_eq(&basic_target, &retrieved_basic.unwrap())); + assert!(Rc::ptr_eq(&azure_target, &retrieved_azure.unwrap())); + assert!(Rc::ptr_eq(&k8s_target, &retrieved_k8s.unwrap())); + assert!(Rc::ptr_eq(&api_target, &retrieved_api.unwrap())); +} + +#[test] +fn test_target_with_invalid_names() { + let target = create_basic_target("invalid.test.target"); + + // Test invalid names + assert!(TARGET_REGISTRY.register("", target.clone()).is_err()); + assert!(TARGET_REGISTRY.register(" ", target.clone()).is_err()); + assert!(TARGET_REGISTRY.register("\t", target.clone()).is_err()); + assert!(TARGET_REGISTRY.register("\n", target).is_err()); +} + +#[test] +fn test_target_duplicate_registration() { + let target = create_basic_target("duplicate.target.test"); + + // First registration should succeed + assert!(TARGET_REGISTRY + .register("duplicate.target.test", target.clone()) + .is_ok()); + + // Duplicate registration should fail + let duplicate_result = TARGET_REGISTRY.register("duplicate.target.test", target); + assert!(duplicate_result.is_err()); + + // Verify error type + match duplicate_result.unwrap_err() { + TargetRegistryError::AlreadyExists { name, .. } => { + assert_eq!(name.as_ref(), "duplicate.target.test"); + } + _ => panic!("Expected AlreadyExists error"), + } +} + +#[test] +fn test_target_removal() { + // Register multiple targets with unique names + let targets = vec![ + ( + "removal.basic.target", + create_basic_target("removal.basic.target"), + ), + ( + "removal.azure.target", + create_azure_target("removal.azure.target"), + ), + ( + "removal.kubernetes.target", + create_kubernetes_target("removal.kubernetes.target"), + ), + ( + "removal.api.target", + create_api_target("removal.api.target"), + ), + ]; + + for (name, target) in &targets { + assert!(TARGET_REGISTRY.register(*name, target.clone()).is_ok()); + } + + // Remove one target + let removed = TARGET_REGISTRY.remove("removal.azure.target"); + assert!(removed.is_some()); + assert!(!TARGET_REGISTRY.contains("removal.azure.target")); + + // Verify the removed target is correct + let removed_target = removed.unwrap(); + assert!(Rc::ptr_eq(&targets[1].1, &removed_target)); + + // Other targets should still be present + assert!(TARGET_REGISTRY.contains("removal.basic.target")); + assert!(TARGET_REGISTRY.contains("removal.kubernetes.target")); + assert!(TARGET_REGISTRY.contains("removal.api.target")); +} + +#[test] +fn test_target_list_operations() { + // Register targets with predictable names + let target_names = vec![ + "list.ops.basic", + "list.ops.azure", + "list.ops.kubernetes", + "list.ops.api", + ]; + + let basic_target = create_basic_target("list.ops.basic"); + + // Register all targets + for name in &target_names { + assert!(TARGET_REGISTRY + .register(*name, basic_target.clone()) + .is_ok()); + } + + // Test list_names() + let names = TARGET_REGISTRY.list_names(); + for expected_name in &target_names { + assert!( + names.contains(&(*expected_name).into()), + "Name list missing {expected_name}" + ); + } + + // Test len() + let initial_len = TARGET_REGISTRY.len(); + assert!(initial_len >= target_names.len()); + + // Test is_empty() - should be false since we have targets + assert!(!TARGET_REGISTRY.is_empty()); + + // Test iter() + let mut found_count = 0; + for (name, _target) in TARGET_REGISTRY.iter() { + if target_names.contains(&name.as_ref()) { + found_count += 1; + } + } + assert_eq!(found_count, target_names.len()); + + // Test list_items() + let items = TARGET_REGISTRY.list_items(); + assert!(items.len() >= target_names.len()); +} + +#[test] +#[cfg(feature = "std")] +fn test_concurrent_target_access() { + use std::sync::Barrier; + use std::thread; + + let barrier = Rc::new(Barrier::new(4)); + let mut handles = vec![]; + + // Test concurrent registration of different targets + let target_names = [ + "concurrent.basic.target", + "concurrent.azure.target", + "concurrent.kubernetes.target", + "concurrent.api.target", + ]; + + for (i, target_name) in target_names.iter().enumerate() { + let barrier = Rc::clone(&barrier); + let name: String = (*target_name).into(); + + let handle: thread::JoinHandle> = + thread::spawn(move || { + let target = match i { + 0 => create_basic_target(&name), + 1 => create_azure_target(&name), + 2 => create_kubernetes_target(&name), + 3 => create_api_target(&name), + _ => unreachable!(), + }; + + barrier.wait(); + TARGET_REGISTRY.register(name, target) + }); + + 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()); + } + + // All targets should be registered + assert!(TARGET_REGISTRY.contains("concurrent.basic.target")); + assert!(TARGET_REGISTRY.contains("concurrent.azure.target")); + assert!(TARGET_REGISTRY.contains("concurrent.kubernetes.target")); + assert!(TARGET_REGISTRY.contains("concurrent.api.target")); +} + +#[test] +fn test_target_versioning() { + // Test targets with different versions + let v1_target_json = json!({ + "name": "versioned_target", + "description": "Version 1.0 of the target", + "version": "1.0.0", + "resource_schema_selector": "type", + "resource_schemas": [ + { + "type": "object", + "properties": { + "type": { "const": "user" }, + "name": { "type": "string" } + }, + "required": ["type", "name"] + } + ], + "effects": { + "allow": { "type": "boolean" } + } + }); + + let v2_target_json = json!({ + "name": "versioned_target", + "description": "Version 2.0 of the target with enhanced features", + "version": "2.0.0", + "resource_schema_selector": "type", + "resource_schemas": [ + { + "type": "object", + "properties": { + "type": { "const": "user" }, + "name": { "type": "string" }, + "email": { "type": "string" } + }, + "required": ["type", "name", "email"] + }, + { + "type": "object", + "properties": { + "type": { "const": "group" }, + "name": { "type": "string" }, + "members": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["type", "name"] + } + ], + "effects": { + "allow": { "type": "boolean" }, + "deny": { "type": "boolean" }, + "audit": { "type": "string" } + } + }); + + let v1_target = Rc::new(Target::from_json_str(&v1_target_json.to_string()).unwrap()); + let v2_target = Rc::new(Target::from_json_str(&v2_target_json.to_string()).unwrap()); + + // Register different versions + assert!(TARGET_REGISTRY + .register("versioned.target.v1", v1_target.clone()) + .is_ok()); + assert!(TARGET_REGISTRY + .register("versioned.target.v2", v2_target.clone()) + .is_ok()); + + // Verify both versions are registered + assert!(TARGET_REGISTRY.contains("versioned.target.v1")); + assert!(TARGET_REGISTRY.contains("versioned.target.v2")); + + // Verify version differences + let retrieved_v1 = TARGET_REGISTRY.get("versioned.target.v1").unwrap(); + let retrieved_v2 = TARGET_REGISTRY.get("versioned.target.v2").unwrap(); + + assert_eq!(retrieved_v1.version.as_ref(), "1.0.0"); + assert_eq!(retrieved_v2.version.as_ref(), "2.0.0"); + + assert_eq!(retrieved_v1.resource_schemas.len(), 1); + assert_eq!(retrieved_v2.resource_schemas.len(), 2); + + assert_eq!(retrieved_v1.effects.len(), 1); + assert_eq!(retrieved_v2.effects.len(), 3); +} + +#[test] +fn test_target_domain_specific_patterns() { + // Test targets for different domains with specific naming patterns + let domain_targets = vec![ + "finance.trading.target", + "healthcare.patient.target", + "retail.inventory.target", + "gaming.player.target", + "education.student.target", + "iot.device.target", + ]; + + let basic_target = create_basic_target("domain.specific.target"); + + // Register all domain targets + for domain_target in &domain_targets { + let result = TARGET_REGISTRY.register(*domain_target, basic_target.clone()); + assert!(result.is_ok(), "Failed to register {domain_target}"); + } + + // Verify all are registered + for domain_target in &domain_targets { + assert!( + TARGET_REGISTRY.contains(domain_target), + "Missing {domain_target}" + ); + assert!( + TARGET_REGISTRY.get(domain_target).is_some(), + "Cannot retrieve {domain_target}" + ); + } + + // Verify list contains all domains + let names = TARGET_REGISTRY.list_names(); + for domain_target in &domain_targets { + assert!( + names.contains(&(*domain_target).into()), + "Name list missing {domain_target}" + ); + } +} + +#[test] +fn test_microsoft_graph_target() { + let msgraph_target = create_msgraph_target("microsoft.graph.api.target"); + + // Test registration of Microsoft Graph target + let result = TARGET_REGISTRY.register("microsoft.graph.api.target", msgraph_target.clone()); + assert!(result.is_ok()); + assert!(TARGET_REGISTRY.contains("microsoft.graph.api.target")); + + // Verify target structure + let retrieved = TARGET_REGISTRY.get("microsoft.graph.api.target"); + assert!(retrieved.is_some()); + let target = retrieved.unwrap(); + + assert_eq!(target.name.as_ref(), "microsoft.graph.api.target"); + assert_eq!(target.version.as_ref(), "1.0.0"); + assert_eq!(target.resource_schema_selector.as_ref(), "@odata.type"); + assert_eq!(target.resource_schemas.len(), 4); + assert_eq!(target.effects.len(), 4); + + // Verify lookup table was populated for Microsoft Graph entities + assert_eq!(target.resource_schema_lookup.len(), 4); + assert!(target + .resource_schema_lookup + .contains_key(&Value::String("#microsoft.graph.user".into()))); + assert!(target + .resource_schema_lookup + .contains_key(&Value::String("#microsoft.graph.group".into()))); + assert!(target + .resource_schema_lookup + .contains_key(&Value::String("#microsoft.graph.application".into()))); + assert!(target + .resource_schema_lookup + .contains_key(&Value::String("#microsoft.graph.device".into()))); + + // Verify no default schema (all have discriminator) + assert!(target.default_resource_schema.is_none()); + + // Verify pointer equality + assert!(Rc::ptr_eq(&msgraph_target, &target)); + + // Test that all expected Microsoft Graph entity types are present + assert!(target + .resource_schema_lookup + .contains_key(&Value::String("#microsoft.graph.user".into()))); + assert!(target + .resource_schema_lookup + .contains_key(&Value::String("#microsoft.graph.group".into()))); + assert!(target + .resource_schema_lookup + .contains_key(&Value::String("#microsoft.graph.application".into()))); + assert!(target + .resource_schema_lookup + .contains_key(&Value::String("#microsoft.graph.device".into()))); +} + +#[test] +fn test_microsoft_graph_with_global_registry() { + // Test registration using global helper functions + let target_name = "global.msgraph.target"; + let msgraph_target = create_msgraph_target(target_name); + assert!(targets::register(msgraph_target.clone()).is_ok()); + + // Verify registration in global registry + assert!(targets::contains(target_name)); + + // Test retrieval from global registry + let retrieved = targets::get(target_name); + assert!(retrieved.is_some()); + + let target = retrieved.unwrap(); + assert_eq!(target.name.as_ref(), target_name); + assert_eq!(target.resource_schema_selector.as_ref(), "@odata.type"); + + // Verify Microsoft Graph specific schemas are present + assert!(target + .resource_schema_lookup + .contains_key(&Value::String("#microsoft.graph.user".into()))); + assert!(target + .resource_schema_lookup + .contains_key(&Value::String("#microsoft.graph.group".into()))); + assert!(target + .resource_schema_lookup + .contains_key(&Value::String("#microsoft.graph.application".into()))); + assert!(target + .resource_schema_lookup + .contains_key(&Value::String("#microsoft.graph.device".into()))); + + // Verify effects include conditional access policies + assert!(target.effects.contains_key("conditional_access")); + assert!(target.effects.contains_key("audit")); + + // Verify pointer equality + assert!(Rc::ptr_eq(&msgraph_target, &target)); +} + +#[test] +fn test_microsoft_graph_concurrent_access() { + use std::sync::Barrier; + use std::thread; + + let barrier = Rc::new(Barrier::new(2)); + let mut handles = vec![]; + + // Test concurrent registration of Microsoft Graph targets + let target_names = ["concurrent.msgraph.users", "concurrent.msgraph.apps"]; + + for target_name in target_names.iter() { + let barrier = Rc::clone(&barrier); + let name: String = (*target_name).into(); + + let handle: thread::JoinHandle> = + thread::spawn(move || { + let target = create_msgraph_target(&name); + barrier.wait(); + TARGET_REGISTRY.register(name, target) + }); + + 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()); + } + + // Both Microsoft Graph targets should be registered + assert!(TARGET_REGISTRY.contains("concurrent.msgraph.users")); + assert!(TARGET_REGISTRY.contains("concurrent.msgraph.apps")); + + // Verify both targets have the expected structure + for target_name in &target_names { + let target = TARGET_REGISTRY.get(target_name).unwrap(); + assert_eq!(target.name.as_ref(), *target_name); + assert_eq!(target.resource_schemas.len(), 4); + assert_eq!(target.resource_schema_lookup.len(), 4); + } +} + +#[test] +fn test_azure_relationship_policies_target() { + let azure_target = + create_azure_relationship_policies_target("azure.relationship.policies.target"); + + // Test registration of Azure relationship policies target + let result = + TARGET_REGISTRY.register("azure.relationship.policies.target", azure_target.clone()); + assert!(result.is_ok()); + assert!(TARGET_REGISTRY.contains("azure.relationship.policies.target")); + + // Verify target structure + let retrieved = TARGET_REGISTRY.get("azure.relationship.policies.target"); + assert!(retrieved.is_some()); + let target = retrieved.unwrap(); + + assert_eq!(target.name.as_ref(), "azure.relationship.policies.target"); + assert_eq!(target.version.as_ref(), "1.0.0"); + assert_eq!(target.resource_schema_selector.as_ref(), "type"); + assert_eq!(target.resource_schemas.len(), 1); // Single universal schema + assert_eq!(target.effects.len(), 1); // Only "manage" effect + + // Verify universal schema covers all Azure resource types + assert!(target.default_resource_schema.is_some()); + + // Verify "manage" effect structure + assert!(target.effects.contains_key("manage")); + + // Verify pointer equality + assert!(Rc::ptr_eq(&azure_target, &target)); +} + +#[test] +fn test_azure_relationship_policies_with_global_registry() { + // Test registration using global helper functions + let target_name = "global.azure.relationship.policies"; + let azure_target = create_azure_relationship_policies_target(target_name); + assert!(targets::register(azure_target.clone()).is_ok()); + + // Verify registration in global registry + assert!(targets::contains(target_name)); + + // Test retrieval from global registry + let retrieved = targets::get(target_name); + assert!(retrieved.is_some()); + + let target = retrieved.unwrap(); + assert_eq!(target.name.as_ref(), "global.azure.relationship.policies"); + assert_eq!(target.resource_schema_selector.as_ref(), "type"); + + // Verify universal schema approach - single schema for all Azure resources + assert!(target.default_resource_schema.is_some()); + assert_eq!(target.resource_schemas.len(), 1); + + // Verify manage effect is present + assert!(target.effects.contains_key("manage")); + + // Verify pointer equality + assert!(Rc::ptr_eq(&azure_target, &target)); +} + +#[test] +fn test_azure_relationship_policies_manage_effect() { + let azure_target = + create_azure_relationship_policies_target("test.azure.relationship.policies"); + + // Verify manage effect exists and is the only effect for relationship policies + assert_eq!(azure_target.effects.len(), 1); + assert!(azure_target.effects.contains_key("manage")); +} + +#[test] +fn test_target_empty_resource_schemas() { + // Create a target with empty resource schemas array - should fail validation + let target_json = json!({ + "name": "empty_resource_schemas", + "version": "1.0.0", + "resource_schema_selector": "type", + "resource_schemas": [], // Empty array + "effects": { + "allow": { + "type": "boolean" + } + } + }); + + // Target creation should fail due to empty resource schemas + let result = Target::from_json_str(&target_json.to_string()); + assert!(result.is_err()); + + match result.unwrap_err() { + TargetError::EmptyResourceSchemas(msg) => { + assert!(msg.contains("Target must have at least one resource schema defined")); + } + other => panic!("Expected EmptyResourceSchemas error, got: {:?}", other), + } +} + +#[test] +fn test_target_empty_effects() { + // Create a target with empty effects - should fail validation + let target_json = json!({ + "name": "empty_effects", + "version": "1.0.0", + "resource_schema_selector": "type", + "resource_schemas": [ + { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["name"] + } + ], + "effects": {} // Empty object + }); + + // Target creation should fail due to empty effects + let result = Target::from_json_str(&target_json.to_string()); + assert!(result.is_err()); + + match result.unwrap_err() { + TargetError::EmptyEffectSchemas(msg) => { + assert!(msg.contains("Target must have at least one effect defined")); + } + other => panic!("Expected EmptyEffectSchemas error, got: {:?}", other), + } +} + +#[test] +fn test_target_single_schema_no_constant_selector() { + // Create a target with one schema that doesn't have a constant property + // matching the resource_schema_selector + let target_json = json!({ + "name": "single_schema_no_constant", + "version": "1.0.0", + "resource_schema_selector": "type", + "resource_schemas": [ + { + "type": "object", + "properties": { + "name": {"type": "string"}, + "value": {"type": "string"}, + "type": {"type": "string"} // This is a regular property, not a constant + }, + "required": ["name"] + } + ], + "effects": { + "allow": { + "type": "boolean" + } + } + }); + + let target = Rc::new(Target::from_json_str(&target_json.to_string()).unwrap()); + + // Test registration + let result = TARGET_REGISTRY.register("single.schema.no.constant", target.clone()); + assert!(result.is_ok()); + + // Verify target structure + let retrieved = TARGET_REGISTRY.get("single.schema.no.constant").unwrap(); + assert_eq!(retrieved.name.as_ref(), "single_schema_no_constant"); + assert_eq!(retrieved.version.as_ref(), "1.0.0"); + assert_eq!(retrieved.resource_schema_selector.as_ref(), "type"); + assert_eq!(retrieved.resource_schemas.len(), 1); + assert_eq!(retrieved.effects.len(), 1); + + // Since the schema doesn't have a constant property matching the selector, + // it should become the default schema and the lookup table should be empty + assert!(retrieved.default_resource_schema.is_some()); + assert!(retrieved.resource_schema_lookup.is_empty()); // No constant discriminator values + + // Verify pointer equality + assert!(Rc::ptr_eq(&target, &retrieved)); +} diff --git a/src/schema.rs b/src/schema.rs index 01d54a77..bfa7886f 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -301,7 +301,7 @@ impl Schema { } /// Returns a reference to the underlying type definition. - fn as_type(&self) -> &Type { + pub fn as_type(&self) -> &Type { &self.t } @@ -311,12 +311,13 @@ impl Schema { schema: serde_json::Value, ) -> Result> { let meta_schema_validation_result = meta::validate_schema_detailed(&schema); - let result = serde_json::from_value::(schema) + let schema = serde_json::from_value::(schema) .map_err(|e| format!("Failed to parse schema: {e}"))?; if let Err(errors) = meta_schema_validation_result { return Err(format!("Schema validation failed: {}", errors.join("\n")).into()); } - Ok(result) + + Ok(schema) } /// Parse a JSON Schema document from a string into a `Schema` instance. @@ -326,6 +327,31 @@ impl Schema { serde_json::from_str(s).map_err(|e| format!("Failed to parse schema: {e}"))?; Self::from_serde_json_value(value) } + + /// Validates a `Value` against this schema. + /// + /// Returns `Ok(())` if the value conforms to the schema, or a `ValidationError` + /// with detailed error information if validation fails. + /// + /// # Example + /// ```rust + /// use regorus::schema::Schema; + /// 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"); + /// + /// assert!(schema.validate(&value).is_ok()); + /// ``` + pub fn validate(&self, value: &Value) -> Result<(), error::ValidationError> { + validate::SchemaValidator::validate(value, self) + } } impl<'de> Deserialize<'de> for Schema { diff --git a/src/schema/registry.rs b/src/schema/registry.rs deleted file mode 100644 index c1cc23e4..00000000 --- a/src/schema/registry.rs +++ /dev/null @@ -1,224 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -#![allow(dead_code)] -use crate::{schema::Schema, *}; -use dashmap::DashMap; - -type String = Rc; - -/// Errors that can occur when interacting with the SchemaRegistry. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SchemaRegistryError { - AlreadyExists(String), - InvalidName(String), -} - -impl fmt::Display for SchemaRegistryError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - SchemaRegistryError::AlreadyExists(name) => { - write!(f, "Schema registration failed: A schema with the name '{name}' is already registered.") - } - SchemaRegistryError::InvalidName(name) => { - write!(f, "Schema registration failed: The name '{name}' is invalid (empty or whitespace-only names are not allowed).") - } - } - } -} - -impl core::error::Error for SchemaRegistryError {} - -/// Validates that a schema name is not empty or whitespace-only. -fn validate_name(name: &str) -> Result<(), SchemaRegistryError> { - if name.is_empty() || name.trim().is_empty() { - Err(SchemaRegistryError::InvalidName(String::from(name))) - } else { - Ok(()) - } -} - -/// Thread-safe registry for Schemas using DashMap. -#[derive(Clone, Default)] -pub struct SchemaRegistry { - inner: DashMap>, -} - -#[cfg(feature = "arc")] -lazy_static::lazy_static! { - /// Global singleton instance of resource schemas registry. - /// Only available when using Arc (thread-safe) reference counting. - pub static ref RESOURCE_SCHEMA_REGISTRY: SchemaRegistry = SchemaRegistry::new(); -} - -#[cfg(feature = "arc")] -lazy_static::lazy_static! { - /// Global singleton instance of effect schemas registry. - /// Only available when using Arc (thread-safe) reference counting. - pub static ref EFFECT_SCHEMA_REGISTRY: SchemaRegistry = SchemaRegistry::new(); -} - -impl SchemaRegistry { - /// Create a new, empty registry. - pub fn new() -> Self { - Self { - inner: DashMap::new(), - } - } - - /// Register a schema with a given name. Returns Err if name already exists. - pub fn register( - &self, - name: impl Into, - schema: Rc, - ) -> Result<(), SchemaRegistryError> { - let name = name.into(); - - // Validate the name first - validate_name(&name)?; - - use dashmap::mapref::entry::Entry; - match self.inner.entry(name) { - Entry::Occupied(e) => Err(SchemaRegistryError::AlreadyExists(e.key().clone())), - Entry::Vacant(e) => { - e.insert(schema); - Ok(()) - } - } - } - - /// Retrieve a schema by name, if it exists. - pub fn get(&self, name: &str) -> Option> { - self.inner.get(name).map(|entry| Rc::clone(entry.value())) - } - - /// Remove a schema by name. Returns the removed schema if it existed. - pub fn remove(&self, name: &str) -> Option> { - self.inner.remove(name).map(|(_, v)| v) - } - - /// List all registered schema names. - pub fn list_names(&self) -> Vec { - self.inner.iter().map(|entry| entry.key().clone()).collect() - } - - /// Check if a schema with the given name exists. - pub fn contains(&self, name: &str) -> bool { - self.inner.contains_key(name) - } - - /// Get the number of registered schemas. - 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 schemas from the registry. - pub fn clear(&self) { - self.inner.clear(); - } -} - -/// Helper functions for resource schema registry operations. -/// Only available when using Arc (thread-safe) reference counting. -#[cfg(feature = "arc")] -pub mod resource { - use super::*; - - /// Register a resource schema with a given name. - pub fn register( - name: impl Into, - schema: Rc, - ) -> Result<(), SchemaRegistryError> { - RESOURCE_SCHEMA_REGISTRY.register(name, schema) - } - - /// Retrieve a resource schema by name. - pub fn get(name: &str) -> Option> { - RESOURCE_SCHEMA_REGISTRY.get(name) - } - - /// Remove a resource schema by name. - pub fn remove(name: &str) -> Option> { - RESOURCE_SCHEMA_REGISTRY.remove(name) - } - - /// List all registered resource schema names. - pub fn list_names() -> Vec { - RESOURCE_SCHEMA_REGISTRY.list_names() - } - - /// Check if a resource schema with the given name exists. - pub fn contains(name: &str) -> bool { - RESOURCE_SCHEMA_REGISTRY.contains(name) - } - - /// Get the number of registered resource schemas. - pub fn len() -> usize { - RESOURCE_SCHEMA_REGISTRY.len() - } - - /// Check if the resource schema registry is empty. - pub fn is_empty() -> bool { - RESOURCE_SCHEMA_REGISTRY.is_empty() - } - - /// Clear all resource schemas from the registry. - pub fn clear() { - RESOURCE_SCHEMA_REGISTRY.clear(); - } -} - -/// Helper functions for effect schema registry operations. -/// Only available when using Arc (thread-safe) reference counting. -#[cfg(feature = "arc")] -pub mod effect { - use super::*; - - /// Register an effect schema with a given name. - pub fn register( - name: impl Into, - schema: Rc, - ) -> Result<(), SchemaRegistryError> { - EFFECT_SCHEMA_REGISTRY.register(name, schema) - } - - /// Retrieve an effect schema by name. - pub fn get(name: &str) -> Option> { - EFFECT_SCHEMA_REGISTRY.get(name) - } - - /// Remove an effect schema by name. - pub fn remove(name: &str) -> Option> { - EFFECT_SCHEMA_REGISTRY.remove(name) - } - - /// List all registered effect schema names. - pub fn list_names() -> Vec { - EFFECT_SCHEMA_REGISTRY.list_names() - } - - /// Check if an effect schema with the given name exists. - pub fn contains(name: &str) -> bool { - EFFECT_SCHEMA_REGISTRY.contains(name) - } - - /// Get the number of registered effect schemas. - pub fn len() -> usize { - EFFECT_SCHEMA_REGISTRY.len() - } - - /// Check if the effect schema registry is empty. - pub fn is_empty() -> bool { - EFFECT_SCHEMA_REGISTRY.is_empty() - } - - /// Clear all effect schemas from the registry. - pub fn clear() { - EFFECT_SCHEMA_REGISTRY.clear(); - } -} diff --git a/src/schema/tests.rs b/src/schema/tests.rs index 712a420f..2093adb6 100644 --- a/src/schema/tests.rs +++ b/src/schema/tests.rs @@ -2,8 +2,5 @@ // Licensed under the MIT License. mod azure; -mod effect; -mod registry; -mod resource; mod suite; mod validate; diff --git a/src/schema/tests/effect.rs b/src/schema/tests/effect.rs deleted file mode 100644 index 97640d45..00000000 --- a/src/schema/tests/effect.rs +++ /dev/null @@ -1,970 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -use super::super::registry::*; -use crate::{ - schema::{validate::SchemaValidator, validate::ValidationError, Schema, Type}, - *, -}; -use serde_json::json; - -type String = Rc; - -use std::sync::Mutex; - -lazy_static::lazy_static! { - static ref EFFECT_TEST_LOCK: Mutex<()> = Mutex::new(()); -} - -// Helper function to create a schema for Azure Policy effects -fn create_effect_schema() -> Rc { - 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 { - 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 { - 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 { - 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() { - #[cfg(feature = "std")] - let _lock = EFFECT_TEST_LOCK.lock().unwrap(); - - let registry = SchemaRegistry::new(); - let effect_schema = create_effect_schema(); - - // Test registration of basic effect enum schema - let result = registry.register("azure.policy.effect", effect_schema.clone()); - assert!(result.is_ok()); - assert!(registry.contains("azure.policy.effect")); - assert_eq!(registry.len(), 1); - - // Verify schema can be retrieved - let retrieved = registry.get("azure.policy.effect"); - assert!(retrieved.is_some()); - assert!(Rc::ptr_eq(&effect_schema, &retrieved.unwrap())); -} - -#[test] -fn test_deny_effect_schema() { - #[cfg(feature = "std")] - let _lock = EFFECT_TEST_LOCK.lock().unwrap(); - - let registry = SchemaRegistry::new(); - let deny_schema = create_deny_effect_schema(); - - // Test registration of deny effect schema - let result = registry.register("azure.policy.deny", deny_schema.clone()); - assert!(result.is_ok()); - assert!(registry.contains("azure.policy.deny")); - - // Verify schema structure - match deny_schema.as_type() { - Type::Object { - properties, - required, - .. - } => { - assert!(properties.contains_key("effect")); - assert!(properties.contains_key("description")); - assert!(required.clone().unwrap().contains(&"effect".into())); - } - _ => panic!("Expected deny schema to be an object type"), - } -} - -#[test] -fn test_audit_effect_schema() { - #[cfg(feature = "std")] - let _lock = EFFECT_TEST_LOCK.lock().unwrap(); - - let registry = SchemaRegistry::new(); - let audit_schema = create_audit_effect_schema(); - - // Test registration of audit effect schema - let result = registry.register("azure.policy.audit", audit_schema.clone()); - assert!(result.is_ok()); - assert!(registry.contains("azure.policy.audit")); - - // Verify schema structure - match audit_schema.as_type() { - Type::Object { - properties, - required, - .. - } => { - assert!(properties.contains_key("effect")); - assert!(properties.contains_key("description")); - assert!(properties.contains_key("auditDetails")); - if let Some(req) = required { - assert!(req.contains(&"effect".into())); - } else { - panic!("Expected required field to be present"); - } - - // Check auditDetails structure - let audit_details = properties.get("auditDetails").unwrap(); - match audit_details.as_type() { - Type::Object { - properties: audit_props, - .. - } => { - assert!(audit_props.contains_key("category")); - assert!(audit_props.contains_key("severity")); - } - _ => panic!("Expected auditDetails to be an object"), - } - } - _ => panic!("Expected audit schema to be an object type"), - } -} - -#[test] -fn test_modify_effect_schema() { - #[cfg(feature = "std")] - let _lock = EFFECT_TEST_LOCK.lock().unwrap(); - - let registry = SchemaRegistry::new(); - let modify_schema = create_modify_effect_schema(); - - // Test registration of modify effect schema - let result = registry.register("azure.policy.modify", modify_schema.clone()); - assert!(result.is_ok()); - assert!(registry.contains("azure.policy.modify")); - - // Verify schema structure - match modify_schema.as_type() { - Type::Object { - properties, - required, - .. - } => { - assert!(properties.contains_key("effect")); - assert!(properties.contains_key("description")); - assert!(properties.contains_key("modifyDetails")); - assert!(required.as_ref().unwrap().contains(&"effect".into())); - assert!(required.as_ref().unwrap().contains(&"modifyDetails".into())); - - // Check modifyDetails structure - let modify_details = properties.get("modifyDetails").unwrap(); - match modify_details.as_type() { - Type::Object { - properties: modify_props, - required: modify_required, - .. - } => { - assert!(modify_props.contains_key("roleDefinitionIds")); - assert!(modify_props.contains_key("operations")); - assert!(modify_required - .as_ref() - .unwrap() - .contains(&"roleDefinitionIds".into())); - assert!(modify_required - .as_ref() - .unwrap() - .contains(&"operations".into())); - } - _ => panic!("Expected modifyDetails to be an object"), - } - } - _ => panic!("Expected modify schema to be an object type"), - } -} - -#[test] -fn test_multiple_effect_schemas() { - #[cfg(feature = "std")] - let _lock = EFFECT_TEST_LOCK.lock().unwrap(); - - let registry = SchemaRegistry::new(); - - // Register all effect schemas - let deny_schema = create_deny_effect_schema(); - let audit_schema = create_audit_effect_schema(); - let modify_schema = create_modify_effect_schema(); - - assert!(registry.register("azure.policy.deny", deny_schema).is_ok()); - assert!(registry - .register("azure.policy.audit", audit_schema) - .is_ok()); - assert!(registry - .register("azure.policy.modify", modify_schema) - .is_ok()); - - // Verify all are registered - assert_eq!(registry.len(), 3); - assert!(registry.contains("azure.policy.deny")); - assert!(registry.contains("azure.policy.audit")); - assert!(registry.contains("azure.policy.modify")); - - // Verify they can all be retrieved - assert!(registry.get("azure.policy.deny").is_some()); - assert!(registry.get("azure.policy.audit").is_some()); - assert!(registry.get("azure.policy.modify").is_some()); - - // List all names - let names = registry.list_names(); - assert_eq!(names.len(), 3); - assert!(names.contains(&"azure.policy.deny".into())); - assert!(names.contains(&"azure.policy.audit".into())); - assert!(names.contains(&"azure.policy.modify".into())); -} - -#[test] -fn test_global_effect_registry() { - let _lock = EFFECT_TEST_LOCK.lock().unwrap(); - - // Clear registry - effect::clear(); - - // Register Azure Policy effects - let deny_schema = create_deny_effect_schema(); - let audit_schema = create_audit_effect_schema(); - let modify_schema = create_modify_effect_schema(); - - assert!(effect::register("azure.policy.deny", deny_schema).is_ok()); - std::dbg!(effect::list_names()); - assert!(effect::register("azure.policy.audit", audit_schema).is_ok()); - std::dbg!(effect::list_names()); - assert!(effect::register("azure.policy.modify", modify_schema).is_ok()); - std::dbg!(effect::list_names()); - // Verify all are registered in global registry - - assert_eq!(effect::len(), 3); - assert!(effect::contains("azure.policy.deny")); - assert!(effect::contains("azure.policy.audit")); - assert!(effect::contains("azure.policy.modify")); - - // Test retrieval from global registry - let retrieved_deny = effect::get("azure.policy.deny"); - let retrieved_audit = effect::get("azure.policy.audit"); - let retrieved_modify = effect::get("azure.policy.modify"); - - assert!(retrieved_deny.is_some()); - assert!(retrieved_audit.is_some()); - assert!(retrieved_modify.is_some()); - - // Clean up - effect::clear(); -} - -#[test] -fn test_effect_schema_validation_patterns() { - #[cfg(feature = "std")] - let _lock = EFFECT_TEST_LOCK.lock().unwrap(); - - let registry = SchemaRegistry::new(); - - // 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 = registry.register("azure.policy.complex", schema_rc); - assert!(result.is_ok()); - assert!(registry.contains("azure.policy.complex")); -} - -#[test] -fn test_effect_schema_with_invalid_names() { - #[cfg(feature = "std")] - let _lock = EFFECT_TEST_LOCK.lock().unwrap(); - - let registry = SchemaRegistry::new(); - let effect_schema = create_effect_schema(); - - // Test invalid names - assert!(registry.register("", effect_schema.clone()).is_err()); - assert!(registry.register(" ", effect_schema.clone()).is_err()); - assert!(registry.register("\t", effect_schema.clone()).is_err()); - assert!(registry.register("\n", effect_schema).is_err()); - - // Verify registry is empty - assert!(registry.is_empty()); -} - -#[test] -fn test_effect_schema_duplicate_registration() { - #[cfg(feature = "std")] - let _lock = EFFECT_TEST_LOCK.lock().unwrap(); - - let registry = SchemaRegistry::new(); - let deny_schema = create_deny_effect_schema(); - - // First registration should succeed - assert!(registry - .register("azure.policy.deny", deny_schema.clone()) - .is_ok()); - assert_eq!(registry.len(), 1); - - // Duplicate registration should fail - let duplicate_result = registry.register("azure.policy.deny", 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"); - } - _ => panic!("Expected AlreadyExists error"), - } - - // Registry should still have only one entry - assert_eq!(registry.len(), 1); -} - -#[test] -fn test_azure_policy_effect_removal() { - #[cfg(feature = "std")] - let _lock = EFFECT_TEST_LOCK.lock().unwrap(); - - let registry = SchemaRegistry::new(); - - // Register multiple Azure Policy effects - let effects = vec![ - ("azure.policy.deny", create_deny_effect_schema()), - ("azure.policy.audit", create_audit_effect_schema()), - ("azure.policy.modify", create_modify_effect_schema()), - ]; - - for (name, schema) in &effects { - assert!(registry.register(*name, schema.clone()).is_ok()); - } - - assert_eq!(registry.len(), 3); - - // Remove one effect - let removed = registry.remove("azure.policy.audit"); - assert!(removed.is_some()); - assert_eq!(registry.len(), 2); - assert!(!registry.contains("azure.policy.audit")); - - // 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!(registry.contains("azure.policy.deny")); - assert!(registry.contains("azure.policy.modify")); -} - -#[test] -#[cfg(feature = "std")] -fn test_concurrent_effect_schema_access() { - let _lock = EFFECT_TEST_LOCK.lock().unwrap(); - - use std::sync::Barrier; - use std::thread; - - // Create isolated registry for this test - let test_registry = Rc::new(SchemaRegistry::new()); - let barrier = Rc::new(Barrier::new(3)); - let mut handles = vec![]; - - // Test concurrent registration of different Azure Policy effects - let effects = [ - "azure.policy.deny", - "azure.policy.audit", - "azure.policy.modify", - ]; - - for (i, effect_name) in effects.iter().enumerate() { - let barrier = Rc::clone(&barrier); - let registry = Rc::clone(&test_registry); - let name: String = (*effect_name).into(); - - let handle: thread::JoinHandle> = - 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(); - 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 3 effect schemas registered - assert_eq!(test_registry.len(), 3); - assert!(test_registry.contains("azure.policy.deny")); - assert!(test_registry.contains("azure.policy.audit")); - assert!(test_registry.contains("azure.policy.modify")); -} - -#[test] -fn test_azure_policy_effect_clear() { - #[cfg(feature = "std")] - let _lock = EFFECT_TEST_LOCK.lock().unwrap(); - - let registry = SchemaRegistry::new(); - - // Register multiple Azure Policy effects - assert!(registry - .register("azure.policy.deny", create_deny_effect_schema()) - .is_ok()); - assert!(registry - .register("azure.policy.audit", create_audit_effect_schema()) - .is_ok()); - assert!(registry - .register("azure.policy.modify", create_modify_effect_schema()) - .is_ok()); - - assert_eq!(registry.len(), 3); - assert!(!registry.is_empty()); - - // Clear all effects - registry.clear(); - - assert_eq!(registry.len(), 0); - assert!(registry.is_empty()); - assert!(registry.list_names().is_empty()); - - // Verify specific effects are no longer present - assert!(!registry.contains("azure.policy.deny")); - assert!(!registry.contains("azure.policy.audit")); - assert!(!registry.contains("azure.policy.modify")); -} - -// 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), - } -} diff --git a/src/schema/tests/registry.rs b/src/schema/tests/registry.rs deleted file mode 100644 index e68aac3e..00000000 --- a/src/schema/tests/registry.rs +++ /dev/null @@ -1,503 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -use super::super::registry::*; -use crate::{schema::Schema, *}; -use serde_json::json; - -type String = Rc; - -#[test] -fn test_schema_registry_new() { - let registry = SchemaRegistry::new(); - assert!(registry.is_empty()); - assert_eq!(registry.len(), 0); -} - -#[test] -fn test_schema_registry_register_success() { - let registry = SchemaRegistry::new(); - 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(); - 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()); - assert!(name.contains("test_schema")); - } else { - panic!("Expected AlreadyExists error"); - } -} - -#[test] -fn test_schema_registry_get() { - let registry = SchemaRegistry::new(); - 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(); - 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(); - - // 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(); - 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("test_schema".into()); - let error_message = format!("{error}"); - assert_eq!( - error_message, - "Schema registration failed: A schema with the name 'test_schema' is already registered." - ); - - let invalid_error = SchemaRegistryError::InvalidName(" ".into()); - let invalid_error_message = format!("{invalid_error}"); - assert_eq!(invalid_error_message, "Schema 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()); - - 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()); - - 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 { - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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()); -} diff --git a/src/schema/tests/resource.rs b/src/schema/tests/resource.rs deleted file mode 100644 index ecd15260..00000000 --- a/src/schema/tests/resource.rs +++ /dev/null @@ -1,1878 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -use super::super::registry::*; -use crate::{ - schema::{validate::SchemaValidator, validate::ValidationError, Schema, Type}, - *, -}; -use serde_json::json; - -type String = Rc; - -use std::sync::Mutex; - -lazy_static::lazy_static! { - static ref RESOURCE_TEST_LOCK: Mutex<()> = Mutex::new(()); -} - -// Helper function to create a schema for Azure Resource types -fn create_resource_schema() -> Rc { - 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 { - 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 { - 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 { - 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 _lock = RESOURCE_TEST_LOCK.lock().unwrap(); - - let registry = SchemaRegistry::new(); - let resource_schema = create_resource_schema(); - - // Test registration of basic resource enum schema - let result = registry.register("azure.resource.types", resource_schema.clone()); - assert!(result.is_ok()); - assert!(registry.contains("azure.resource.types")); - assert_eq!(registry.len(), 1); - - // Verify schema can be retrieved - let retrieved = registry.get("azure.resource.types"); - assert!(retrieved.is_some()); - assert!(Rc::ptr_eq(&resource_schema, &retrieved.unwrap())); -} - -#[test] -fn test_vm_resource_schema() { - let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); - - let registry = SchemaRegistry::new(); - let vm_schema = create_vm_resource_schema(); - - // Test registration of VM resource schema - let result = registry.register("azure.resource.vm", vm_schema.clone()); - assert!(result.is_ok()); - assert!(registry.contains("azure.resource.vm")); - - // Verify schema structure - match vm_schema.as_type() { - Type::Object { - properties, - required, - .. - } => { - assert!(properties.contains_key("type")); - assert!(properties.contains_key("apiVersion")); - assert!(properties.contains_key("name")); - assert!(properties.contains_key("location")); - assert!(properties.contains_key("properties")); - - if let Some(req) = required { - assert!(req.contains(&"type".into())); - assert!(req.contains(&"apiVersion".into())); - assert!(req.contains(&"name".into())); - assert!(req.contains(&"location".into())); - assert!(req.contains(&"properties".into())); - } else { - panic!("Expected required fields to be present"); - } - - // Check properties structure - let vm_properties = properties.get("properties").unwrap(); - match vm_properties.as_type() { - Type::Object { - properties: vm_props, - .. - } => { - assert!(vm_props.contains_key("hardwareProfile")); - assert!(vm_props.contains_key("osProfile")); - } - _ => panic!("Expected properties to be an object"), - } - } - _ => panic!("Expected VM schema to be an object type"), - } -} - -#[test] -fn test_storage_resource_schema() { - let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); - - let registry = SchemaRegistry::new(); - let storage_schema = create_storage_resource_schema(); - - // Test registration of storage resource schema - let result = registry.register("azure.resource.storage", storage_schema.clone()); - assert!(result.is_ok()); - assert!(registry.contains("azure.resource.storage")); - - // Verify schema structure - match storage_schema.as_type() { - Type::Object { - properties, - required, - .. - } => { - assert!(properties.contains_key("type")); - assert!(properties.contains_key("apiVersion")); - assert!(properties.contains_key("name")); - assert!(properties.contains_key("location")); - assert!(properties.contains_key("sku")); - assert!(properties.contains_key("kind")); - - if let Some(req) = required { - assert!(req.contains(&"type".into())); - assert!(req.contains(&"apiVersion".into())); - assert!(req.contains(&"name".into())); - assert!(req.contains(&"location".into())); - assert!(req.contains(&"sku".into())); - assert!(req.contains(&"kind".into())); - } else { - panic!("Expected required fields to be present"); - } - - // Check sku structure - let sku = properties.get("sku").unwrap(); - match sku.as_type() { - Type::Object { - properties: sku_props, - .. - } => { - assert!(sku_props.contains_key("name")); - } - _ => panic!("Expected sku to be an object"), - } - } - _ => panic!("Expected storage schema to be an object type"), - } -} - -#[test] -fn test_network_resource_schema() { - let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); - - let registry = SchemaRegistry::new(); - let network_schema = create_network_resource_schema(); - - // Test registration of network resource schema - let result = registry.register("azure.resource.network", network_schema.clone()); - assert!(result.is_ok()); - assert!(registry.contains("azure.resource.network")); - - // Verify schema structure - match network_schema.as_type() { - Type::Object { - properties, - required, - .. - } => { - assert!(properties.contains_key("type")); - assert!(properties.contains_key("apiVersion")); - assert!(properties.contains_key("name")); - assert!(properties.contains_key("location")); - assert!(properties.contains_key("properties")); - - if let Some(req) = required { - assert!(req.contains(&"type".into())); - assert!(req.contains(&"apiVersion".into())); - assert!(req.contains(&"name".into())); - assert!(req.contains(&"location".into())); - assert!(req.contains(&"properties".into())); - } else { - panic!("Expected required fields to be present"); - } - - // Check properties structure - let net_properties = properties.get("properties").unwrap(); - match net_properties.as_type() { - Type::Object { - properties: net_props, - .. - } => { - assert!(net_props.contains_key("addressSpace")); - } - _ => panic!("Expected properties to be an object"), - } - } - _ => panic!("Expected network schema to be an object type"), - } -} - -#[test] -fn test_multiple_resource_schemas() { - let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); - - let registry = SchemaRegistry::new(); - - // Register all resource schemas - let vm_schema = create_vm_resource_schema(); - let storage_schema = create_storage_resource_schema(); - let network_schema = create_network_resource_schema(); - - assert!(registry.register("azure.resource.vm", vm_schema).is_ok()); - assert!(registry - .register("azure.resource.storage", storage_schema) - .is_ok()); - assert!(registry - .register("azure.resource.network", network_schema) - .is_ok()); - - // Verify all are registered - assert_eq!(registry.len(), 3); - assert!(registry.contains("azure.resource.vm")); - assert!(registry.contains("azure.resource.storage")); - assert!(registry.contains("azure.resource.network")); - - // Verify they can all be retrieved - assert!(registry.get("azure.resource.vm").is_some()); - assert!(registry.get("azure.resource.storage").is_some()); - assert!(registry.get("azure.resource.network").is_some()); - - // List all names - let names = registry.list_names(); - assert_eq!(names.len(), 3); - assert!(names.contains(&"azure.resource.vm".into())); - assert!(names.contains(&"azure.resource.storage".into())); - assert!(names.contains(&"azure.resource.network".into())); -} - -#[test] -fn test_global_resource_registry() { - let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); - - // Clear registry - resource::clear(); - - // Register Azure Resource schemas - let vm_schema = create_vm_resource_schema(); - let storage_schema = create_storage_resource_schema(); - let network_schema = create_network_resource_schema(); - - assert!(resource::register("azure.resource.vm", vm_schema).is_ok()); - assert!(resource::register("azure.resource.storage", storage_schema).is_ok()); - assert!(resource::register("azure.resource.network", network_schema).is_ok()); - - // Verify all are registered in global registry - assert_eq!(resource::len(), 3); - assert!(resource::contains("azure.resource.vm")); - assert!(resource::contains("azure.resource.storage")); - assert!(resource::contains("azure.resource.network")); - - // Test retrieval from global registry - let retrieved_vm = resource::get("azure.resource.vm"); - let retrieved_storage = resource::get("azure.resource.storage"); - let retrieved_network = resource::get("azure.resource.network"); - - assert!(retrieved_vm.is_some()); - assert!(retrieved_storage.is_some()); - assert!(retrieved_network.is_some()); - - // Clean up - resource::clear(); -} - -#[test] -fn test_resource_schema_validation_patterns() { - let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); - - let registry = SchemaRegistry::new(); - - // 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 = registry.register("azure.template.arm", schema_rc); - assert!(result.is_ok()); - assert!(registry.contains("azure.template.arm")); -} - -#[test] -fn test_resource_schema_with_invalid_names() { - let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); - - let registry = SchemaRegistry::new(); - let resource_schema = create_resource_schema(); - - // Test invalid names - assert!(registry.register("", resource_schema.clone()).is_err()); - assert!(registry.register(" ", resource_schema.clone()).is_err()); - assert!(registry.register("\t", resource_schema.clone()).is_err()); - assert!(registry.register("\n", resource_schema).is_err()); - - // Verify registry is empty - assert!(registry.is_empty()); -} - -#[test] -fn test_resource_schema_duplicate_registration() { - let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); - - let registry = SchemaRegistry::new(); - let vm_schema = create_vm_resource_schema(); - - // First registration should succeed - assert!(registry - .register("azure.resource.vm", vm_schema.clone()) - .is_ok()); - assert_eq!(registry.len(), 1); - - // Duplicate registration should fail - let duplicate_result = registry.register("azure.resource.vm", 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"); - } - _ => panic!("Expected AlreadyExists error"), - } - - // Registry should still have only one entry - assert_eq!(registry.len(), 1); -} - -#[test] -fn test_azure_resource_removal() { - let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); - - let registry = SchemaRegistry::new(); - - // Register multiple Azure Resource schemas - let resources = vec![ - ("azure.resource.vm", create_vm_resource_schema()), - ("azure.resource.storage", create_storage_resource_schema()), - ("azure.resource.network", create_network_resource_schema()), - ]; - - for (name, schema) in &resources { - assert!(registry.register(*name, schema.clone()).is_ok()); - } - - assert_eq!(registry.len(), 3); - - // Remove one resource - let removed = registry.remove("azure.resource.storage"); - assert!(removed.is_some()); - assert_eq!(registry.len(), 2); - assert!(!registry.contains("azure.resource.storage")); - - // 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!(registry.contains("azure.resource.vm")); - assert!(registry.contains("azure.resource.network")); -} - -#[test] -#[cfg(feature = "std")] -fn test_concurrent_resource_schema_access() { - let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); - - use std::sync::Barrier; - use std::thread; - - // Create isolated registry for this test - let test_registry = Rc::new(SchemaRegistry::new()); - let barrier = Rc::new(Barrier::new(3)); - let mut handles = vec![]; - - // Test concurrent registration of different Azure Resource schemas - let resources = [ - "azure.resource.vm", - "azure.resource.storage", - "azure.resource.network", - ]; - - for (i, resource_name) in resources.iter().enumerate() { - let barrier = Rc::clone(&barrier); - let registry = Rc::clone(&test_registry); - let name: String = (*resource_name).into(); - - let handle: thread::JoinHandle> = - 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(); - 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 3 resource schemas registered - assert_eq!(test_registry.len(), 3); - assert!(test_registry.contains("azure.resource.vm")); - assert!(test_registry.contains("azure.resource.storage")); - assert!(test_registry.contains("azure.resource.network")); -} - -#[test] -fn test_azure_resource_clear() { - let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); - - let registry = SchemaRegistry::new(); - - // Register multiple Azure Resource schemas - assert!(registry - .register("azure.resource.vm", create_vm_resource_schema()) - .is_ok()); - assert!(registry - .register("azure.resource.storage", create_storage_resource_schema()) - .is_ok()); - assert!(registry - .register("azure.resource.network", create_network_resource_schema()) - .is_ok()); - - assert_eq!(registry.len(), 3); - assert!(!registry.is_empty()); - - // Clear all resources - registry.clear(); - - assert_eq!(registry.len(), 0); - assert!(registry.is_empty()); - assert!(registry.list_names().is_empty()); - - // Verify specific resources are no longer present - assert!(!registry.contains("azure.resource.vm")); - assert!(!registry.contains("azure.resource.storage")); - assert!(!registry.contains("azure.resource.network")); -} - -#[test] -fn test_resource_type_validation() { - let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); - - let registry = SchemaRegistry::new(); - - // Test different Azure resource types with specific naming patterns - let resource_types = vec![ - "azure.compute.vm", - "azure.storage.account", - "azure.network.vnet", - "azure.keyvault.vault", - "azure.sql.database", - "azure.webapp.site", - ]; - - let basic_schema = create_resource_schema(); - - // Register all resource types - for resource_type in &resource_types { - let result = registry.register(*resource_type, basic_schema.clone()); - assert!(result.is_ok(), "Failed to register {resource_type}"); - } - - // Verify all are registered - assert_eq!(registry.len(), resource_types.len()); - - for resource_type in &resource_types { - assert!(registry.contains(resource_type), "Missing {resource_type}"); - assert!( - registry.get(resource_type).is_some(), - "Cannot retrieve {resource_type}" - ); - } - - // Verify list contains all types - let names = registry.list_names(); - assert_eq!(names.len(), resource_types.len()); - - for resource_type in &resource_types { - assert!( - names.contains(&(*resource_type).into()), - "Name list missing {resource_type}" - ); - } -} - -// Schema validation tests for Azure Resource schemas - -#[test] -fn test_validate_vm_resource_valid() { - let schema = create_vm_resource_schema(); - - let valid_vm_data = json!({ - "type": "Microsoft.Compute/virtualMachines", - "apiVersion": "2021-03-01", - "name": "my-vm-01", - "location": "eastus", - "properties": { - "hardwareProfile": { - "vmSize": "Standard_B2s" - }, - "osProfile": { - "computerName": "my-computer", - "adminUsername": "azureuser" - } - } - }); - - let value = Value::from(valid_vm_data); - let result = SchemaValidator::validate(&value, &schema); - assert!(result.is_ok()); -} - -#[test] -fn test_validate_vm_resource_missing_required() { - let schema = create_vm_resource_schema(); - - let invalid_vm_data = json!({ - "type": "Microsoft.Compute/virtualMachines", - "apiVersion": "2021-03-01", - "name": "my-vm-01" - // Missing location and properties - }); - - let value = Value::from(invalid_vm_data); - let result = SchemaValidator::validate(&value, &schema); - assert!(result.is_err()); - - match result.unwrap_err() { - ValidationError::MissingRequiredProperty { property, .. } => { - // Should be missing either location or properties - assert!(property == "location".into() || property == "properties".into()); - } - other => panic!("Expected MissingRequiredProperty error, got: {:?}", other), - } -} - -#[test] -fn test_validate_vm_resource_invalid_vm_size() { - let schema = create_vm_resource_schema(); - - let invalid_vm_data = json!({ - "type": "Microsoft.Compute/virtualMachines", - "apiVersion": "2021-03-01", - "name": "my-vm-01", - "location": "eastus", - "properties": { - "hardwareProfile": { - "vmSize": "InvalidSize" - }, - "osProfile": { - "computerName": "my-computer", - "adminUsername": "azureuser" - } - } - }); - - let value = Value::from(invalid_vm_data); - let result = SchemaValidator::validate(&value, &schema); - assert!(result.is_err()); - - match result.unwrap_err() { - ValidationError::PropertyValidationFailed { - property, error, .. - } => { - assert_eq!(property, "properties".into()); - match error.as_ref() { - ValidationError::PropertyValidationFailed { - property: inner_prop, - error: inner_error, - .. - } => { - assert_eq!(*inner_prop, "hardwareProfile".into()); - match inner_error.as_ref() { - ValidationError::PropertyValidationFailed { - property: vm_size_prop, - error: vm_size_error, - .. - } => { - assert_eq!(*vm_size_prop, "vmSize".into()); - match vm_size_error.as_ref() { - ValidationError::NotInEnum { .. } => { - // Expected deeply nested error structure - } - other => { - panic!("Expected NotInEnum error for vmSize, got: {:?}", other) - } - } - } - other => panic!( - "Expected PropertyValidationFailed for vmSize, got: {:?}", - other - ), - } - } - other => panic!( - "Expected PropertyValidationFailed for hardwareProfile, got: {:?}", - other - ), - } - } - other => panic!("Expected PropertyValidationFailed error, got: {:?}", other), - } -} - -#[test] -fn test_validate_storage_resource_valid() { - let schema = create_storage_resource_schema(); - - let valid_storage_data = json!({ - "type": "Microsoft.Storage/storageAccounts", - "apiVersion": "2021-04-01", - "name": "mystorageaccount001", - "location": "westus2", - "sku": { - "name": "Standard_LRS" - }, - "kind": "StorageV2", - "properties": { - "accessTier": "Hot", - "encryption": { - "services": {} - } - } - }); - - let value = Value::from(valid_storage_data); - let result = SchemaValidator::validate(&value, &schema); - assert!(result.is_ok()); -} - -#[test] -fn test_validate_storage_resource_invalid_name() { - let schema = create_storage_resource_schema(); - - let invalid_storage_data = json!({ - "type": "Microsoft.Storage/storageAccounts", - "apiVersion": "2021-04-01", - "name": "Invalid-Storage-Name-With-Caps-And-Dashes", - "location": "westus2", - "sku": { - "name": "Standard_LRS" - }, - "kind": "StorageV2" - }); - - let value = Value::from(invalid_storage_data); - let result = SchemaValidator::validate(&value, &schema); - assert!(result.is_err()); - - match result.unwrap_err() { - ValidationError::PropertyValidationFailed { - property, error, .. - } => { - assert_eq!(property, "name".into()); - match error.as_ref() { - ValidationError::PatternMismatch { .. } => { - // Expected pattern mismatch for storage account name - } - other => panic!("Expected PatternMismatch error for name, got: {:?}", other), - } - } - other => panic!("Expected PropertyValidationFailed error, got: {:?}", other), - } -} - -#[test] -fn test_validate_storage_resource_invalid_sku() { - let schema = create_storage_resource_schema(); - - let invalid_storage_data = json!({ - "type": "Microsoft.Storage/storageAccounts", - "apiVersion": "2021-04-01", - "name": "mystorageaccount001", - "location": "westus2", - "sku": { - "name": "Invalid_SKU" - }, - "kind": "StorageV2" - }); - - let value = Value::from(invalid_storage_data); - let result = SchemaValidator::validate(&value, &schema); - assert!(result.is_err()); - - match result.unwrap_err() { - ValidationError::PropertyValidationFailed { - property, error, .. - } => { - assert_eq!(property, "sku".into()); - match error.as_ref() { - ValidationError::PropertyValidationFailed { - property: sku_prop, - error: sku_error, - .. - } => { - assert_eq!(*sku_prop, "name".into()); - match sku_error.as_ref() { - ValidationError::NotInEnum { .. } => { - // Expected enum validation error - } - other => panic!("Expected NotInEnum error for sku name, got: {:?}", other), - } - } - other => panic!( - "Expected PropertyValidationFailed for sku name, got: {:?}", - other - ), - } - } - other => panic!("Expected PropertyValidationFailed error, got: {:?}", other), - } -} - -#[test] -fn test_validate_network_resource_valid() { - let schema = create_network_resource_schema(); - - let valid_network_data = json!({ - "type": "Microsoft.Network/virtualNetworks", - "apiVersion": "2021-02-01", - "name": "my-vnet", - "location": "eastus", - "properties": { - "addressSpace": { - "addressPrefixes": ["10.0.0.0/16"] - }, - "subnets": [ - { - "name": "default", - "properties": { - "addressPrefix": "10.0.1.0/24" - } - } - ] - } - }); - - let value = Value::from(valid_network_data); - let result = SchemaValidator::validate(&value, &schema); - assert!(result.is_ok()); -} - -#[test] -fn test_validate_network_resource_invalid_address_prefix() { - let schema = create_network_resource_schema(); - - let invalid_network_data = json!({ - "type": "Microsoft.Network/virtualNetworks", - "apiVersion": "2021-02-01", - "name": "my-vnet", - "location": "eastus", - "properties": { - "addressSpace": { - "addressPrefixes": ["invalid-cidr"] - } - } - }); - - let value = Value::from(invalid_network_data); - let result = SchemaValidator::validate(&value, &schema); - assert!(result.is_err()); - - match result.unwrap_err() { - ValidationError::PropertyValidationFailed { - property, error, .. - } => { - assert_eq!(property, "properties".into()); - match error.as_ref() { - ValidationError::PropertyValidationFailed { - property: addr_prop, - .. - } => { - assert_eq!(*addr_prop, "addressSpace".into()); - // Continue checking nested structure for array validation - } - other => panic!( - "Expected PropertyValidationFailed for addressSpace, got: {:?}", - other - ), - } - } - other => panic!("Expected PropertyValidationFailed error, got: {:?}", other), - } -} - -#[test] -fn test_validate_basic_resource_enum() { - let schema = create_resource_schema(); - - // Test all valid resource types - let valid_types = [ - "Microsoft.Compute/virtualMachines", - "Microsoft.Storage/storageAccounts", - "Microsoft.Network/virtualNetworks", - ]; - - for resource_type in valid_types { - let value = Value::from(resource_type); - let result = SchemaValidator::validate(&value, &schema); - assert!( - result.is_ok(), - "Resource type '{resource_type}' should be valid" - ); - } - - // Test invalid resource type - let invalid_value = Value::from("Microsoft.Invalid/resourceType"); - 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_arm_template() { - let complex_schema_json = json!({ - "type": "object", - "properties": { - "$schema": { - "type": "string" - }, - "contentVersion": { - "type": "string" - }, - "parameters": { - "type": "object", - "additionalProperties": { "type": "any" } - }, - "variables": { - "type": "object", - "additionalProperties": { "type": "any" } - }, - "resources": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "apiVersion": { - "type": "string" - }, - "name": { - "type": "string" - }, - "location": { - "type": "string" - }, - "properties": { - "type": "object", - "additionalProperties": { "type": "any" } - }, - "tags": { - "type": "object", - "additionalProperties": { "type": "string" } - } - }, - "required": ["type", "apiVersion", "name"], - "additionalProperties": { "type": "any" } - } - }, - "outputs": { - "type": "object", - "additionalProperties": { "type": "any" } - } - }, - "required": ["resources"], - "additionalProperties": { "type": "any" } - }); - - let schema = Schema::from_serde_json_value(complex_schema_json).unwrap(); - - let valid_template_data = json!({ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "vmName": { - "type": "string", - "defaultValue": "myVM" - } - }, - "variables": { - "storageAccountName": "[concat('storage', uniqueString(resourceGroup().id))]" - }, - "resources": [ - { - "type": "Microsoft.Compute/virtualMachines", - "apiVersion": "2021-03-01", - "name": "[parameters('vmName')]", - "location": "[resourceGroup().location]", - "properties": { - "hardwareProfile": { - "vmSize": "Standard_B1s" - } - }, - "tags": { - "environment": "dev", - "project": "test" - } - } - ], - "outputs": { - "vmId": { - "type": "string", - "value": "[resourceId('Microsoft.Compute/virtualMachines', parameters('vmName'))]" - } - } - }); - - let value = Value::from(valid_template_data); - let result = SchemaValidator::validate(&value, &schema); - assert!(result.is_ok()); -} - -#[test] -fn test_validate_resource_type_mismatch() { - let schema = create_vm_resource_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), - } -} - -#[test] -fn test_complex_nested_azure_template_validation() { - let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); - - // Create a complex ARM template schema with deeply nested properties - let complex_schema = json!({ - "type": "object", - "properties": { - "$schema": { "type": "string" }, - "contentVersion": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$" }, - "metadata": { - "type": "object", - "properties": { - "description": { "type": "string" }, - "author": { "type": "string" }, - "tags": { - "type": "object", - "additionalProperties": { "type": "string" } - } - } - }, - "parameters": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "type": { "enum": ["string", "int", "bool", "array", "object"] }, - "defaultValue": { "type": "any" }, - "allowedValues": { "type": "array", "items": { "type": "any" } }, - "minValue": { "type": "number" }, - "maxValue": { "type": "number" }, - "minLength": { "type": "integer" }, - "maxLength": { "type": "integer" }, - "metadata": { - "type": "object", - "properties": { - "description": { "type": "string" }, - "strongType": { "type": "string" } - } - } - }, - "required": ["type"] - } - }, - "variables": { "type": "object" }, - "resources": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { "type": "string" }, - "apiVersion": { "type": "string" }, - "name": { "type": "string" }, - "location": { "type": "string" }, - "dependsOn": { - "type": "array", - "items": { "type": "string" } - }, - "condition": { "type": "boolean" }, - "copy": { - "type": "object", - "properties": { - "name": { "type": "string" }, - "count": { "type": "integer", "minimum": 1, "maximum": 800 }, - "mode": { "enum": ["Parallel", "Serial"] }, - "batchSize": { "type": "integer", "minimum": 1 } - }, - "required": ["name", "count"] - }, - "properties": { "type": "object" }, - "tags": { - "type": "object", - "additionalProperties": { "type": "string" } - } - }, - "required": ["type", "apiVersion", "name"] - } - }, - "outputs": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "type": { "enum": ["string", "int", "bool", "array", "object"] }, - "value": { "type": "any" }, - "condition": { "type": "boolean" }, - "copy": { - "type": "object", - "properties": { - "count": { "type": "integer", "minimum": 1 }, - "input": { "type": "any" } - }, - "required": ["count", "input"] - } - }, - "required": ["type", "value"] - } - } - }, - "required": ["$schema", "contentVersion", "resources"] - }); - - let schema = Schema::from_serde_json_value(complex_schema).unwrap(); - - // Valid complex template with nested copy loops and conditions - let valid_template = json!({ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.2.3.4", - "metadata": { - "description": "Complex deployment with copy loops and conditions", - "author": "Azure DevOps Team", - "tags": { - "environment": "production", - "cost-center": "engineering", - "department": "cloud-infrastructure" - } - }, - "parameters": { - "vmCount": { - "type": "int", - "defaultValue": 3, - "minValue": 1, - "maxValue": 10, - "metadata": { - "description": "Number of VMs to deploy", - "strongType": "Microsoft.Compute/SKUs" - } - }, - "environment": { - "type": "string", - "defaultValue": "dev", - "allowedValues": ["dev", "test", "staging", "prod"], - "metadata": { - "description": "Environment name for resource naming" - } - }, - "enableMonitoring": { - "type": "bool", - "defaultValue": true, - "metadata": { - "description": "Whether to enable monitoring extensions" - } - } - }, - "variables": { - "vmPrefix": "[concat(parameters('environment'), '-vm-')]", - "storageAccountName": "[concat('storage', uniqueString(resourceGroup().id))]" - }, - "resources": [ - { - "type": "Microsoft.Storage/storageAccounts", - "apiVersion": "2021-04-01", - "name": "[variables('storageAccountName')]", - "location": "eastus", - "properties": { - "accountType": "Standard_LRS" - }, - "tags": { - "purpose": "vm-diagnostics", - "environment": "[parameters('environment')]" - } - }, - { - "type": "Microsoft.Compute/virtualMachines", - "apiVersion": "2021-03-01", - "name": "[concat(variables('vmPrefix'), copyIndex(1))]", - "location": "eastus", - "condition": true, - "dependsOn": [ - "[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))]" - ], - "copy": { - "name": "vmLoop", - "count": 5, - "mode": "Parallel", - "batchSize": 2 - }, - "properties": { - "hardwareProfile": { - "vmSize": "Standard_B2s" - } - }, - "tags": { - "environment": "[parameters('environment')]", - "vm-index": "[string(copyIndex())]" - } - } - ], - "outputs": { - "storageAccountId": { - "type": "string", - "value": "[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))]" - }, - "vmIds": { - "type": "array", - "copy": { - "count": 5, - "input": "[resourceId('Microsoft.Compute/virtualMachines', concat(variables('vmPrefix'), copyIndex(1)))]" - }, - "value" : "[resourceId('Microsoft.Compute/virtualMachines', concat(variables('vmPrefix'), copyIndex(1)))]" - } - } - }); - - let value = Value::from(valid_template); - let result = SchemaValidator::validate(&value, &schema); - std::dbg!(&result); - assert!( - result.is_ok(), - "Complex valid template should pass validation" - ); - - // Test invalid template with constraint violations - let invalid_template = json!({ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "invalid-version", // Should match pattern - "resources": [ - { - "type": "Microsoft.Compute/virtualMachines", - "apiVersion": "2021-03-01", - "name": "test-vm", - "copy": { - "name": "vmLoop", - "count": 1000 // Exceeds maximum of 800 - } - } - ] - }); - - let value = Value::from(invalid_template); - let result = SchemaValidator::validate(&value, &schema); - assert!( - result.is_err(), - "Template with constraint violations should fail" - ); -} - -#[test] -fn test_deep_nesting_and_recursive_structures() { - let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); - - // Schema with moderate nesting (5 levels) to avoid macro recursion limits - let deep_schema = json!({ - "type": "object", - "properties": { - "level1": { - "type": "object", - "properties": { - "level2": { - "type": "object", - "properties": { - "level3": { - "type": "object", - "properties": { - "level4": { - "type": "object", - "properties": { - "level5": { - "type": "object", - "properties": { - "deepValue": { - "type": "string", - "pattern": "^deep-[0-9]+$" - }, - "recursiveArray": { - "type": "array", - "items": { - "type": "object", - "properties": { - "nested": { - "type": "object", - "properties": { - "value": { "type": "number" }, - "metadata": { - "type": "object", - "additionalProperties": { "type": "string" } - } - } - } - } - } - } - }, - "required": ["deepValue"] - } - } - } - } - } - } - } - } - } - }, - "required": ["level1"] - }); - - let schema = Schema::from_serde_json_value(deep_schema).unwrap(); - - // Valid deeply nested structure - let valid_deep_data = json!({ - "level1": { - "level2": { - "level3": { - "level4": { - "level5": { - "deepValue": "deep-12345", - "recursiveArray": [ - { - "nested": { - "value": 42.5, - "metadata": { - "type": "numeric", - "unit": "percentage", - "source": "sensor-1" - } - } - }, - { - "nested": { - "value": 15.3, - "metadata": { - "type": "numeric", - "unit": "temperature", - "source": "sensor-2" - } - } - } - ] - } - } - } - } - } - }); - - let value = Value::from(valid_deep_data); - let result = SchemaValidator::validate(&value, &schema); - assert!(result.is_ok(), "Valid deeply nested structure should pass"); - - // Invalid - missing required deepValue - let invalid_deep_data = json!({ - "level1": { - "level2": { - "level3": { - "level4": { - "level5": { - // Missing required "deepValue" - "recursiveArray": [] - } - } - } - } - } - }); - - let value = Value::from(invalid_deep_data); - let result = SchemaValidator::validate(&value, &schema); - assert!( - result.is_err(), - "Structure missing required deep field should fail" - ); -} - -#[test] -fn test_unicode_and_internationalization_validation() { - let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); - - // Schema supporting international characters and Unicode - let unicode_schema = json!({ - "type": "object", - "properties": { - "names": { - "type": "object", - "properties": { - "chinese": { "type": "string", "pattern": "^[\\u4e00-\\u9fff]+$" }, - "russian": { "type": "string", "pattern": "^[\\u0400-\\u04ff]+$" }, - "japanese": { "type": "string", "pattern": "^[\\u3040-\\u309f\\u30a0-\\u30ff\\u4e00-\\u9fff]+$" }, - "korean": { "type": "string", "pattern": "^[\\uac00-\\ud7af]+$" }, - "hindi": { "type": "string", "pattern": "^[\\u0900-\\u097f]+$" }, - "french": { "type": "string", "pattern": "^[a-zA-ZàâäéèêëïîôöùûüÿñæœÀÂÄÉÈÊËÏÎÔÖÙÛÜŸÑÆŒ\\s-]+$" } - } - }, - "descriptions": { - "type": "object", - "additionalProperties": { - "type": "string", - "minLength": 1, - "maxLength": 1000 - } - }, - "metadata": { - "type": "object", - "properties": { - "encoding": { "enum": ["UTF-8", "UTF-16", "UTF-32"] }, - "locale": { "type": "string", "pattern": "^[a-z]{2}-[A-Z]{2}$" }, - "timezone": { "type": "string" } - } - } - }, - "required": ["names", "metadata"] - }); - - let schema = Schema::from_serde_json_value(unicode_schema).unwrap(); - - // Valid international data - let valid_unicode_data = json!({ - "names": { - "chinese": "你好世界", - "russian": "привет", - "japanese": "こんにちは世界", - "korean": "안녕하세요", - "hindi": "नमस्ते", - "french": "Bonjour le Monde" - }, - "descriptions": { - "en-US": "Hello World application for international users", - "zh-CN": "面向国际用户的你好世界应用程序", - "ru-RU": "Приложение Hello World для международных пользователей", - "ja-JP": "国際ユーザー向けのHello Worldアプリケーション", - "ko-KR": "국제 사용자를 위한 Hello World 애플리케이션", - "hi-IN": "अंतर्राष्ट्रीय उपयोगकर्ताओं के लिए हैलो वर्ल्ड एप्लिकेशन", - "fr-FR": "Application Hello World pour les utilisateurs internationaux" - }, - "metadata": { - "encoding": "UTF-8", - "locale": "en-US", - "timezone": "UTC" - } - }); - - let value = Value::from(valid_unicode_data); - let result = SchemaValidator::validate(&value, &schema); - assert!(result.is_ok(), "Valid Unicode data should pass validation"); - - // Invalid - non-matching Unicode patterns - let invalid_unicode_data = json!({ - "names": { - "chinese": "Hello", // Should be Chinese characters - "russian": "Goodbye", // Should be Russian characters - "japanese": "Test", // Should be Japanese characters - "korean": "Invalid", // Should be Korean characters - "hindi": "Wrong", // Should be Hindi characters - "french": "123456" // Should be French text - }, - "metadata": { - "encoding": "UTF-8", - "locale": "invalid-locale", // Should match pattern - "timezone": "UTC" - } - }); - - let value = Value::from(invalid_unicode_data); - let result = SchemaValidator::validate(&value, &schema); - assert!( - result.is_err(), - "Invalid Unicode patterns should fail validation" - ); -} - -#[test] -fn test_edge_cases_and_boundary_conditions() { - let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); - - // Schema with strict boundary conditions - let boundary_schema = json!({ - "type": "object", - "properties": { - "strings": { - "type": "object", - "properties": { - "empty": { "type": "string", "minLength": 0, "maxLength": 0 }, - "single": { "type": "string", "minLength": 1, "maxLength": 1 }, - "exact_length": { "type": "string", "minLength": 10, "maxLength": 10 }, - "very_long": { "type": "string", "maxLength": 10000 } - } - }, - "numbers": { - "type": "object", - "properties": { - "zero": { "type": "number", "minimum": 0, "maximum": 0 }, - "negative": { "type": "number", "minimum": -1000, "maximum": -1 }, - "positive": { "type": "number", "minimum": 1, "maximum": 1000 }, - "float_precision": { "type": "number" } - } - }, - "arrays": { - "type": "object", - "properties": { - "empty": { "type": "array", "minItems": 0, "maxItems": 0, "items": { "type": "string" } }, - "single_item": { "type": "array", "minItems": 1, "maxItems": 1, "items": { "type": "string" } }, - "exact_size": { "type": "array", "minItems": 5, "maxItems": 5, "items": { "type": "integer" } }, - "large_array": { "type": "array", "maxItems": 1000, "items": { "type": "boolean" } } - } - }, - "objects": { - "type": "object", - "properties": { - "empty": { "type": "object", "additionalProperties": false }, - "single_prop": { - "type": "object", - "properties": { "only": { "type": "string" } }, - "additionalProperties": false, - "required": ["only"] - } - } - }, - "nulls_and_optionals": { - "type": "object", - "properties": { - "nullable": { "type": "string" }, - "optional": { "type": "string" }, - "required_null": { "type": "null" } - }, - "required": ["required_null"] - } - }, - "required": ["strings", "numbers", "arrays", "objects", "nulls_and_optionals"] - }); - - let schema = Schema::from_serde_json_value(boundary_schema).unwrap(); - - // Valid boundary condition data - let valid_boundary_data = json!({ - "strings": { - "empty": "", - "single": "a", - "exact_length": "exactly_10", - "very_long": "a".repeat(9999) - }, - "numbers": { - "zero": 0, - "negative": -500, - "positive": 250, - "float_precision": 123.45 - }, - "arrays": { - "empty": [], - "single_item": ["test"], - "exact_size": [1, 2, 3, 4, 5], - "large_array": vec![true; 500] - }, - "objects": { - "empty": {}, - "single_prop": { - "only": "value" - } - }, - "nulls_and_optionals": { - "nullable": "string_value", - "optional": "present", - "required_null": null - } - }); - - let value = Value::from(valid_boundary_data); - let result = SchemaValidator::validate(&value, &schema); - assert!( - result.is_ok(), - "Valid boundary conditions should pass validation" - ); - - // Invalid boundary violations - let invalid_boundary_data = json!({ - "strings": { - "empty": "not empty", // Should be empty - "single": "too long", // Should be exactly 1 character - "exact_length": "wrong", // Should be exactly 10 characters - "very_long": "a".repeat(10001) // Exceeds maximum length - }, - "numbers": { - "zero": 0.1, // Should be exactly 0 - "negative": 1, // Should be negative - "positive": -1, // Should be positive - "float_precision": 123.456 // Wrong precision - }, - "arrays": { - "empty": ["not empty"], // Should be empty - "single_item": [], // Should have exactly 1 item - "exact_size": [1, 2, 3], // Should have exactly 5 items - "large_array": vec![true; 1001] // Exceeds maximum items - }, - "objects": { - "empty": { "should_be_empty": true }, // Should have no properties - "single_prop": {} // Missing required property - }, - "nulls_and_optionals": { - "nullable": "should allow null or string", - "required_null": "should be null" // Should be null - } - }); - - let value = Value::from(invalid_boundary_data); - let result = SchemaValidator::validate(&value, &schema); - assert!( - result.is_err(), - "Boundary violations should fail validation" - ); -} - -#[test] -fn test_concurrent_schema_validation_stress() { - use std::sync::Arc; - use std::thread; - - let _lock = RESOURCE_TEST_LOCK.lock().unwrap(); - - // Create a complex schema for concurrent testing - let concurrent_schema = json!({ - "type": "object", - "properties": { - "id": { "type": "string", "pattern": "^[a-zA-Z0-9-]{8,64}$" }, - "timestamp": { "type": "string" }, - "data": { - "type": "object", - "properties": { - "values": { - "type": "array", - "items": { "type": "number" }, - "minItems": 1, - "maxItems": 100 - }, - "metadata": { - "type": "object", - "additionalProperties": { "type": "string" } - } - }, - "required": ["values"] - } - }, - "required": ["id", "timestamp", "data"] - }); - - let schema = Arc::new(Schema::from_serde_json_value(concurrent_schema).unwrap()); - - // Spawn multiple threads to validate concurrently - let mut handles = vec![]; - - for thread_id in 0..10 { - let schema_clone = Arc::clone(&schema); - - let handle = thread::spawn(move || { - let mut results = Vec::new(); - - for i in 0..100 { - let test_data = json!({ - "id": format!("thread-{}-item-{}", thread_id, i), - "timestamp": "2023-12-31T23:59:59Z", - "data": { - "values": vec![1.0, 2.0, 3.0, (i as f64)], - "metadata": { - "thread": thread_id.to_string(), - "iteration": i.to_string(), - "test_type": "concurrent" - } - } - }); - - let value = Value::from(test_data); - let result = SchemaValidator::validate(&value, &schema_clone); - results.push(result.is_ok()); - } - - results - }); - - handles.push(handle); - } - - // Wait for all threads and collect results - let mut all_results = Vec::new(); - for handle in handles { - let thread_results = handle.join().expect("Thread should complete successfully"); - all_results.extend(thread_results); - } - - // All validations should pass - let successful_validations = all_results.iter().filter(|&&result| result).count(); - assert_eq!( - successful_validations, 1000, - "All 1000 concurrent validations should pass" - ); -} diff --git a/src/target.rs b/src/target.rs new file mode 100644 index 00000000..cd61fd31 --- /dev/null +++ b/src/target.rs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#![allow(dead_code)] +use crate::{Rc, Schema, Value, Vec}; +use alloc::collections::BTreeMap; +use serde::Deserialize; + +mod deserialize; +mod error; +mod resource_schema_selector; + +type String = Rc; + +use deserialize::{deserialize_effects, deserialize_resource_schemas}; +pub use error::TargetError; + +/// A target defines the domain for which a set of policies are written. +/// It specifies the types of input resources, possible policy effects, +/// and configuration for policy evaluation. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Target { + /// Name of the target domain + /// A Rego module can specify a target by defining a rule named `__target__`: + /// __target__ = "my_target" + pub name: String, + + /// Description of what this target is for + pub description: Option, + + /// Version of the target + pub version: String, + + /// Types of input resources that policies can evaluate + #[serde(deserialize_with = "deserialize_resource_schemas")] + pub resource_schemas: Vec>, + + /// The discriminator property that can be used to select + /// a specific resource schema + pub resource_schema_selector: String, + + /// Set of effects that policies can produce + #[serde(deserialize_with = "deserialize_effects")] + pub effects: BTreeMap>, + /// Lookup table for resource schemas by discrimiator values. + #[serde(skip)] + pub resource_schema_lookup: BTreeMap>, + + /// Resource chemas that cannot be distinguished by the discriminator + #[serde(skip)] + pub default_resource_schema: Option>, +} + +impl Target { + pub fn from_json_str(json: &str) -> Result { + let mut target: Target = serde_json::from_str(json).map_err(TargetError::from)?; + + // Validate that resource schemas is not empty + if target.resource_schemas.is_empty() { + return Err(TargetError::EmptyResourceSchemas( + "Target must have at least one resource schema defined".into(), + )); + } + + if target.effects.is_empty() { + return Err(TargetError::EmptyEffectSchemas( + "Target must have at least one effect defined".into(), + )); + } + + resource_schema_selector::populate_target_lookup_fields(&mut target)?; + Ok(target) + } +} + +#[cfg(test)] +mod tests { + mod deserialize; +} diff --git a/src/target/deserialize.rs b/src/target/deserialize.rs new file mode 100644 index 00000000..58b30406 --- /dev/null +++ b/src/target/deserialize.rs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::registry::instances::{EFFECT_SCHEMA_REGISTRY, RESOURCE_SCHEMA_REGISTRY}; +use crate::{format, Rc, Schema, Vec}; +use alloc::collections::BTreeMap; +use serde::de::{Deserializer, Error}; +use serde::Deserialize; +type String = Rc; + +/// Deserialize resource schemas from either an array of schemas or schema names. +/// If specified as schema names, look them up from RESOURCE_SCHEMA_REGISTRY. +pub fn deserialize_resource_schemas<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let array: Vec = Vec::deserialize(deserializer) + .map_err(|e| D::Error::custom(format!("Failed to deserialize resource_schemas: {}", e)))?; + + let mut schemas = Vec::new(); + + for item in array.into_iter() { + let schema = + if let Some(name) = item.as_str() { + // Look up schema by name in the registry + RESOURCE_SCHEMA_REGISTRY.get(name).ok_or_else(|| { + D::Error::custom(format!("Resource schema '{}' not found in registry", name)) + })? + } else { + // Treat as a direct schema definition + Rc::new(Schema::deserialize(item.clone()).map_err(|e| { + D::Error::custom(format!("Failed to deserialize schema: {}", e)) + })?) + }; + + // Assert that the schema represents an object type + if !matches!(schema.as_type(), crate::schema::Type::Object { .. }) { + return Err(D::Error::custom("Resource schema must be an object type")); + } + + schemas.push(schema); + } + + Ok(schemas) +} + +/// Deserialize effects from either an object of schemas or schema names. +/// If specified as schema names, look them up from EFFECT_SCHEMA_REGISTRY. +pub fn deserialize_effects<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let object: BTreeMap = BTreeMap::deserialize(deserializer) + .map_err(|e| D::Error::custom(format!("Failed to deserialize effects: {}", e)))?; + + let mut effects = BTreeMap::new(); + + for (key, item) in object.into_iter() { + if let Some(name) = item.as_str() { + // Look up schema by name in the registry + let schema = EFFECT_SCHEMA_REGISTRY.get(name).ok_or_else(|| { + D::Error::custom(format!("Effect schema '{}' not found in registry", name)) + })?; + effects.insert(key, schema); + } else { + // Treat as a direct schema definition + let schema = Schema::deserialize(item.clone()) + .map_err(|e| D::Error::custom(format!("Failed to deserialize schema: {}", e)))?; + effects.insert(key, Rc::new(schema)); + } + } + + Ok(effects) +} diff --git a/src/target/error.rs b/src/target/error.rs new file mode 100644 index 00000000..513c12d0 --- /dev/null +++ b/src/target/error.rs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::{format, Rc}; + +type String = Rc; + +/// Error type for target parsing operations. +#[derive(Debug, Clone, thiserror::Error)] +pub enum TargetError { + /// JSON parsing error + #[error("JSON parse error: {0}")] + JsonParseError(String), + /// Target deserialization error + #[error("Deserialization error: {0}")] + DeserializationError(String), + /// Duplicate constant value error + #[error("Duplicate constant value: {0}")] + DuplicateConstantValue(String), + /// Multiple default resource schemas error + #[error("Multiple default schemas: {0}")] + MultipleDefaultSchemas(String), + /// Empty resource schemas error + #[error("Empty resource schemas: {0}")] + EmptyResourceSchemas(String), + /// Empty effect schemas error + #[error("Empty effect schemas: {0}")] + EmptyEffectSchemas(String), +} + +impl From for TargetError { + fn from(error: serde_json::Error) -> Self { + TargetError::JsonParseError(format!("{}", error).into()) + } +} diff --git a/src/target/resource_schema_selector.rs b/src/target/resource_schema_selector.rs new file mode 100644 index 00000000..d0a81d50 --- /dev/null +++ b/src/target/resource_schema_selector.rs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::schema::Type; +use crate::{format, Rc, Schema, Value}; +use alloc::collections::BTreeMap; + +type String = Rc; + +use super::{Target, TargetError}; + +/// Populates the resource_schema_lookup and default_resource_schema fields +/// in a Target based on its resource_schema_selector field and resource_schemas. +/// +/// This function analyzes each resource schema to: +/// - Find constant properties that match the selector field name +/// - Build a lookup table mapping constant values to schemas +/// - Collect schemas that don't have the constant property +/// - Raise an error if duplicate constant values are found +pub fn populate_target_lookup_fields(target: &mut Target) -> Result<(), TargetError> { + target.resource_schema_lookup.clear(); + target.default_resource_schema = None; + + // Track which schema index corresponds to each constant value + let mut value_to_index = BTreeMap::new(); + + // Analyze each schema for constant properties + for (index, schema) in target.resource_schemas.iter().enumerate() { + if let Some(constant_value) = + find_constant_property(schema, &target.resource_schema_selector) + { + // Check if this constant value already exists + if let Some(existing_index) = value_to_index.get(&constant_value) { + return Err(TargetError::DuplicateConstantValue(format!( + "Duplicate constant value '{}' found for resource schema selector field '{}' in schemas at indexes {} and {}", + constant_value, + target.resource_schema_selector, + existing_index, + index + ).into())); + } + // Record the mapping and add to lookup table + value_to_index.insert(constant_value.clone(), index); + target + .resource_schema_lookup + .insert(constant_value, schema.clone()); + } else { + // Schema doesn't have the constant property + if target.default_resource_schema.is_some() { + return Err(TargetError::MultipleDefaultSchemas(format!( + "Multiple schemas found without discriminator property '{}'. Only one default resource schema is allowed.", + target.resource_schema_selector + ).into())); + } + target.default_resource_schema = Some(schema.clone()); + } + } + + Ok(()) +} + +/// Finds a constant property value in a schema for the specified field name. +/// Returns the constant value if found, or None if the schema doesn't have +/// a constant property for the given field. +fn find_constant_property(schema: &Rc, field_name: &str) -> Option { + match schema.as_type() { + Type::Object { properties, .. } => { + // Look for the field in the schema's properties + if let Some(property_schema) = properties.get(field_name) { + match property_schema.as_type() { + Type::Const { value, .. } => { + // Found a constant property - return its value + Some(value.clone()) + } + _ => { + // Property exists but is not a constant + None + } + } + } else { + // Field doesn't exist in this schema + None + } + } + _ => { + // Schema is not an object type + None + } + } +} diff --git a/src/target/tests/deserialize.rs b/src/target/tests/deserialize.rs new file mode 100644 index 00000000..13850234 --- /dev/null +++ b/src/target/tests/deserialize.rs @@ -0,0 +1,358 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::super::*; +use crate::Value; +use alloc::string::ToString; +use serde_json::json; + +#[test] +fn test_target_deserialization_with_direct_schemas() { + let target_json = json!({ + "name": "test_target", + "description": "A test target for validation", + "version": "1.0.0", + "resource_schema_selector": "type", + "resource_schemas": [ + { + "type": "object", + "properties": { + "name": { "type": "string" }, + "type": { "const": "user" } + }, + "required": ["name", "type"] + }, + { + "type": "object", + "properties": { + "name": { "type": "string" }, + "type": { "const": "group" } + }, + "required": ["name", "type"] + } + ], + "effects": { + "allow": { "type": "boolean" }, + "deny": { "type": "boolean" } + } + }); + + let target = Target::from_json_str(&target_json.to_string()).unwrap(); + + assert_eq!(target.name.as_ref(), "test_target"); + assert_eq!( + target.description.as_ref().unwrap().as_ref(), + "A test target for validation" + ); + assert_eq!(target.version.as_ref(), "1.0.0"); + assert_eq!(target.resource_schema_selector.as_ref(), "type"); + assert_eq!(target.resource_schemas.len(), 2); + assert_eq!(target.effects.len(), 2); + + // Check that lookup table was populated + assert_eq!(target.resource_schema_lookup.len(), 2); + assert!(target + .resource_schema_lookup + .contains_key(&Value::String("user".into()))); + assert!(target + .resource_schema_lookup + .contains_key(&Value::String("group".into()))); + + // Check that default_resource_schema is None since all schemas have the discriminator + assert!(target.default_resource_schema.is_none()); +} + +#[test] +fn test_target_deserialization_with_mixed_schemas() { + let target_json = json!({ + "name": "mixed_target", + "version": "1.0.0", + "resource_schema_selector": "resourceType", + "resource_schemas": [ + { + "type": "object", + "properties": { + "id": { "type": "string" }, + "resourceType": { "const": "storage" } + }, + "required": ["id", "resourceType"] + }, + { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + }, + "required": ["id"] + } + ], + "effects": { + "permit": { "type": "string" } + } + }); + + let target = Target::from_json_str(&target_json.to_string()).unwrap(); + + assert_eq!(target.name.as_ref(), "mixed_target"); + assert!(target.description.is_none()); + assert_eq!(target.resource_schema_selector.as_ref(), "resourceType"); + + // One schema has discriminator, one doesn't + assert_eq!(target.resource_schema_lookup.len(), 1); + assert!(target + .resource_schema_lookup + .contains_key(&Value::String("storage".into()))); + + assert!(target.default_resource_schema.is_some()); +} + +#[test] +fn test_target_deserialization_multiple_default_schemas_error() { + let target_json = json!({ + "name": "multiple_default_target", + "version": "1.0.0", + "resource_schema_selector": "kind", + "resource_schemas": [ + { + "type": "object", + "properties": { + "id": { "type": "string" }, + "data": { "type": "object" } + }, + "required": ["id"] + }, + { + "type": "object", + "properties": { + "name": { "type": "string" }, + "value": { "type": "number" } + }, + "required": ["name"] + } + ], + "effects": { + "allow": { "type": "boolean" }, + "deny": { "type": "boolean" } + } + }); + + // No schemas have the discriminator field - this should fail with MultipleDefaultSchemas error + let result = Target::from_json_str(&target_json.to_string()); + assert!(result.is_err()); + + let error = result.unwrap_err(); + assert!(matches!(error, TargetError::MultipleDefaultSchemas(_))); +} + +#[test] +fn test_target_deserialization_single_default_schema() { + let target_json = json!({ + "name": "single_default_target", + "version": "1.0.0", + "resource_schema_selector": "kind", + "resource_schemas": [ + { + "type": "object", + "properties": { + "id": { "type": "string" }, + "data": { "type": "object" } + }, + "required": ["id"] + } + ], + "effects": { + "allow": { "type": "boolean" } + } + }); + + let target = Target::from_json_str(&target_json.to_string()).unwrap(); + + // Single schema without discriminator should work fine + assert_eq!(target.resource_schema_lookup.len(), 0); + assert!(target.default_resource_schema.is_some()); +} + +#[test] +fn test_target_deserialization_duplicate_discriminator_error() { + let target_json = json!({ + "name": "duplicate_target", + "version": "1.0.0", + "resource_schema_selector": "type", + "resource_schemas": [ + { + "type": "object", + "properties": { + "id": { "type": "string" }, + "type": { "const": "duplicate" } + }, + "required": ["id", "type"] + }, + { + "type": "object", + "properties": { + "name": { "type": "string" }, + "type": { "const": "duplicate" } + }, + "required": ["name", "type"] + } + ], + "effects": { + "allow": { "type": "boolean" } + } + }); + + let result = Target::from_json_str(&target_json.to_string()); + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(matches!(error, TargetError::DuplicateConstantValue(_))); +} + +#[test] +fn test_target_deserialization_missing_required_field() { + let target_json = json!({ + "name": "incomplete_target", + "version": "1.0.0", + // Missing resource_schema_selector + "resource_schemas": [ + { + "type": "object", + "properties": { + "id": { "type": "string" } + } + } + ], + "effects": {} + }); + + let result = Target::from_json_str(&target_json.to_string()); + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(matches!( + error, + TargetError::JsonParseError(_) | TargetError::DeserializationError(_) + )); +} + +#[test] +fn test_target_deserialization_invalid_json() { + let invalid_json = "{ invalid json }"; + + let result = Target::from_json_str(invalid_json); + assert!(result.is_err()); + + let error = result.unwrap_err(); + assert!(matches!(error, TargetError::JsonParseError(_))); +} + +#[test] +fn test_target_deserialization_with_registry_schemas() { + // This test assumes that there are some schemas in the registries + // If the registries are empty, this test will fail with appropriate errors + + let target_json = json!({ + "name": "registry_target", + "version": "1.0.0", + "resource_schema_selector": "type", + "resource_schemas": [ + "some_registry_schema_name" // This will be looked up from RESOURCE_SCHEMA_REGISTRY + ], + "effects": { + "allow": "some_effect_schema_name" // This will be looked up from EFFECT_SCHEMA_REGISTRY + } + }); + + // This test will likely fail if the registries are empty, but it demonstrates + // the structure for testing registry-based schema resolution + let result = Target::from_json_str(&target_json.to_string()); + + // We expect this to fail with a "not found in registry" error since we haven't + // populated the registries with test data + if result.is_err() { + let error = result.unwrap_err(); + assert!(matches!( + error, + TargetError::JsonParseError(_) | TargetError::DeserializationError(_) + )); + } +} + +#[test] +fn test_target_deserialization_numeric_discriminator() { + let target_json = json!({ + "name": "numeric_target", + "version": "1.0.0", + "resource_schema_selector": "level", + "resource_schemas": [ + { + "type": "object", + "properties": { + "name": { "type": "string" }, + "level": { "const": 1 } + }, + "required": ["name", "level"] + }, + { + "type": "object", + "properties": { + "name": { "type": "string" }, + "level": { "const": 2 } + }, + "required": ["name", "level"] + } + ], + "effects": { + "grant": { "type": "string" } + } + }); + + let target = Target::from_json_str(&target_json.to_string()).unwrap(); + + // Check that numeric discriminator values work + assert_eq!(target.resource_schema_lookup.len(), 2); + assert!(target.resource_schema_lookup.contains_key(&Value::from(1))); + assert!(target.resource_schema_lookup.contains_key(&Value::from(2))); + assert!(target.default_resource_schema.is_none()); +} + +#[test] +fn test_target_deserialization_boolean_discriminator() { + let target_json = json!({ + "name": "boolean_target", + "version": "1.0.0", + "resource_schema_selector": "enabled", + "resource_schemas": [ + { + "type": "object", + "properties": { + "name": { "type": "string" }, + "enabled": { "const": true } + }, + "required": ["name", "enabled"] + }, + { + "type": "object", + "properties": { + "name": { "type": "string" }, + "enabled": { "const": false } + }, + "required": ["name", "enabled"] + } + ], + "effects": { + "activate": { "type": "boolean" } + } + }); + + let target = Target::from_json_str(&target_json.to_string()).unwrap(); + + // Check that boolean discriminator values work + assert_eq!(target.resource_schema_lookup.len(), 2); + assert!(target + .resource_schema_lookup + .contains_key(&Value::from(true))); + assert!(target + .resource_schema_lookup + .contains_key(&Value::from(false))); + assert!(target.default_resource_schema.is_none()); +} diff --git a/src/tests/interpreter/mod.rs b/src/tests/interpreter/mod.rs index 9556dfba..c0a25485 100644 --- a/src/tests/interpreter/mod.rs +++ b/src/tests/interpreter/mod.rs @@ -9,6 +9,119 @@ use anyhow::{bail, Result}; use serde::{ser::SerializeMap, Deserialize, Deserializer, Serialize, Serializer}; use test_generator::test_resources; +#[cfg(feature = "azure_policy")] +mod load_target_definitions { + use super::*; + use std::{eprintln, sync::Once}; + static INIT: Once = Once::new(); + + /// Load and register all target definitions from tests/interpreter/target/definitions + /// This function is called once and loads all JSON target definition files. + pub fn load() -> Result<()> { + INIT.call_once(|| { + if let Err(e) = load_target_definitions_impl() { + eprintln!("Failed to load target definitions: {}", e); + } + }); + Ok(()) + } + + fn load_target_definitions_impl() -> Result<()> { + use crate::registry::targets; + use crate::target::Target; + use std::fs; + use std::path::Path; + + let definitions_path = Path::new("tests/interpreter/cases/target/definitions"); + + if !definitions_path.exists() { + eprintln!("Target definitions directory does not exist"); + return Ok(()); + } + + let entries = fs::read_dir(definitions_path)?; + let mut found = false; + + for entry in entries { + let entry = entry?; + let path = entry.path(); + + // Only process JSON files + if path.extension().and_then(|s| s.to_str()) == Some("json") { + let contents = fs::read_to_string(&path)?; + + match Target::from_json_str(&contents) { + Ok(target) => { + let target_name = target.name.clone(); + let target_rc = Rc::new(target); + found = true; + if let Err(e) = targets::register(target_rc.clone()) { + eprintln!("Failed to register target '{}': {}", target_name, e); + } + } + Err(e) => { + eprintln!( + "Failed to parse target definition from {}: {}", + path.display(), + e + ); + } + } + } + } + + if !found { + eprintln!("No target definitions were found"); + } + + Ok(()) + } + + #[test] + fn test_load_target_definitions() -> Result<()> { + use crate::registry::targets; + + // Load target definitions + let _ = load()?; + + // Check that the sample targets were loaded + assert!( + targets::contains("target.tests.sample_test_target"), + "Sample target should be loaded" + ); + assert!( + targets::contains("target.tests.azure_compute"), + "Azure compute target should be loaded" + ); + + // Verify we can retrieve the targets + let sample_target = targets::get("target.tests.sample_test_target"); + assert!( + sample_target.is_some(), + "Should be able to retrieve sample target" + ); + + let azure_target = targets::get("target.tests.azure_compute"); + assert!( + azure_target.is_some(), + "Should be able to retrieve azure target" + ); + + // Verify target properties + if let Some(target) = sample_target { + assert_eq!(target.name.as_ref(), "target.tests.sample_test_target"); + assert_eq!(target.version.as_ref(), "1.0.0"); + } + + if let Some(target) = azure_target { + assert_eq!(target.name.as_ref(), "target.tests.azure_compute"); + assert_eq!(target.version.as_ref(), "1.0.0"); + } + + Ok(()) + } +} + // Process test value specified in json/yaml to interpret special encodings. pub fn process_value(v: &Value) -> Result { match v { @@ -211,6 +324,64 @@ pub fn eval_file( Ok((results, engine.take_prints()?)) } +#[cfg(feature = "azure_policy")] +pub fn eval_file_with_rule_evaluation( + regos: &[String], + data_opt: Option, + input_opt: Option, + query: &str, + _enable_tracing: bool, + strict: bool, +) -> Result<(Vec, Vec)> { + let mut engine: Engine = Engine::new(); + engine.set_rego_v0(true); + engine.set_strict_builtin_errors(strict); + engine.set_gather_prints(true); + + #[cfg(feature = "coverage")] + engine.set_enable_coverage(true); + + let mut results = vec![]; + let mut files = vec![]; + + for (idx, _) in regos.iter().enumerate() { + files.push(format!("rego_{idx}")); + } + + for (idx, file) in files.iter().enumerate() { + let contents = regos[idx].as_str(); + engine.add_policy(file.to_string(), contents.to_string())?; + } + + if let Some(data) = data_opt { + engine.add_data(data)?; + } + + // Also test using the newer CompilerPolicy API. + let compiled_policy = engine.clone().compile_for_target()?; + + let mut inputs = vec![]; + match input_opt { + Some(ValueOrVec::Single(single_input)) => inputs.push(single_input), + Some(ValueOrVec::Many(mut many_input)) => inputs.append(&mut many_input), + _ => { + // For target tests without input, use an empty object as default + inputs.push(Value::new_object()); + } + } + + for input in inputs { + engine.set_input(input.clone()); + // Use eval_rule instead of eval_query for target tests + let r_engine = engine.eval_rule(query.to_string())?; + let r_compiled_policy = compiled_policy.eval_with_input(input)?; + assert_eq!(r_engine, r_compiled_policy); + results.push(r_engine); + } + + Ok((results, engine.take_prints()?)) +} + #[derive(PartialEq, Debug)] pub enum ValueOrVec { Single(Value), @@ -280,6 +451,9 @@ fn yaml_test_impl(file: &str) -> Result<()> { let yaml_str = std::fs::read_to_string(file)?; let test: YamlTest = serde_yaml::from_str(&yaml_str)?; + #[cfg(feature = "azure_policy")] + load_target_definitions::load().expect("Failed to load target definitions"); + #[cfg(not(feature = "std"))] { // Skip tests that depend on bultins that need std feature. @@ -329,14 +503,36 @@ fn yaml_test_impl(file: &str) -> Result<()> { let enable_tracing = case.traces.is_some() && case.traces.unwrap(); - match eval_file( - &case.modules, - case.data, - case.input, - case.query.as_str(), - enable_tracing, - case.strict, - ) { + let is_target_test = file.contains("target"); + + let result = if is_target_test { + #[cfg(feature = "azure_policy")] + { + eval_file_with_rule_evaluation( + &case.modules, + case.data, + case.input, + case.query.as_str(), + enable_tracing, + case.strict, + ) + } + #[cfg(not(feature = "azure_policy"))] + { + panic!("Target tests require azure_policy feature") + } + } else { + eval_file( + &case.modules, + case.data, + case.input, + case.query.as_str(), + enable_tracing, + case.strict, + ) + }; + + match result { Ok((results, prints)) => match case.want_result { Some(want_result) => { let mut expected_results = vec![]; @@ -392,6 +588,12 @@ fn yaml_test(file: &str) -> Result<()> { return Ok(()); } + // Targets are supported only with azure_policy feature. + #[cfg(not(feature = "azure_policy"))] + if file.contains("target") { + return Ok(()); + } + match yaml_test_impl(file) { Ok(_) => Ok(()), Err(e) => { diff --git a/tests/ensure_no_std/src/main.rs b/tests/ensure_no_std/src/main.rs index d4aff8a4..6f095e1d 100644 --- a/tests/ensure_no_std/src/main.rs +++ b/tests/ensure_no_std/src/main.rs @@ -3,10 +3,9 @@ #![no_std] #![no_main] -use core::panic::PanicInfo; - +#[cfg(not(test))] #[panic_handler] -fn panic(_info: &PanicInfo) -> ! { +fn panic(_info: &core::panic::PanicInfo) -> ! { loop {} } diff --git a/tests/interpreter/cases/builtins/strings/sprintf.yaml b/tests/interpreter/cases/builtins/strings/sprintf.yaml new file mode 100644 index 00000000..1933fbc7 --- /dev/null +++ b/tests/interpreter/cases/builtins/strings/sprintf.yaml @@ -0,0 +1,80 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +cases: + - note: basic string formatting with spaces + data: {} + modules: + - | + package test + + test1 := sprintf("User %s from %s has access", ["alice", "engineering"]) + test2 := sprintf("A %s B %s C", ["X", "Y"]) + test3 := sprintf("No spaces%s%s", ["A", "B"]) + test4 := sprintf("Single %s", ["word"]) + test5 := sprintf("Start %s end", ["middle"]) + query: data.test + want_result: + test1: "User alice from engineering has access" + test2: "A X B Y C" + test3: "No spacesAB" + test4: "Single word" + test5: "Start middle end" + + - note: numeric formatting + data: {} + modules: + - | + package test + + decimal := sprintf("Number: %d", [42]) + float := sprintf("Float: %f", [3.14]) + hex := sprintf("Hex: %x", [255]) + octal := sprintf("Octal: %o", [64]) + binary := sprintf("Binary: %b", [15]) + query: data.test + want_result: + decimal: "Number: 42" + float: "Float: 3.14" + hex: "Hex: ff" + octal: "Octal: 0O100" + binary: "Binary: 1111" + + - note: mixed types and escaping + data: {} + modules: + - | + package test + + mixed := sprintf("String: %s, Number: %d, Percent: %%", ["hello", 123]) + verbose := sprintf("Value: %v", [{"key": "value"}]) + query: data.test + want_result: + mixed: "String: hello, Number: 123, Percent: %" + verbose: "Value: {\"key\": \"value\"}" + + - note: width formatting + data: {} + modules: + - | + package test + + padded := sprintf("Padded: %5d", [42]) + zero_padded := sprintf("Zero padded: %05d", [42]) + decimal_places := sprintf("Decimal: %.2f", [3.14159]) + query: data.test + want_result: + padded: "Padded: 42" + zero_padded: "Zero padded: 00042" + decimal_places: "Decimal: 3.14" + + - note: error cases + data: {} + modules: + - | + package test + + # This should cause an error - missing argument + error_case := sprintf("Value: %s %d", ["only_one"]) + query: data.test + error: "no argument specified for format verb 1" \ No newline at end of file diff --git a/tests/interpreter/cases/target/azure_policy.yaml b/tests/interpreter/cases/target/azure_policy.yaml new file mode 100644 index 00000000..4e906263 --- /dev/null +++ b/tests/interpreter/cases/target/azure_policy.yaml @@ -0,0 +1,79 @@ +cases: + - note: "Azure Policy Basic Allow Test" + data: {} + input: + type: "Microsoft.Storage/storageAccounts" + name: "mystorageaccount" + location: "East US" + kind: "StorageV2" + properties: + supportsHttpsTrafficOnly: true + minimumTlsVersion: "TLS1_2" + tags: + environment: "production" + modules: + - | + package azure.policy.allow + + import rego.v1 + + __target__ := "target.tests.azure_policy" + + default allow := false + + allow if { + input.type == "Microsoft.Storage/storageAccounts" + input.properties.supportsHttpsTrafficOnly == true + } + query: data.azure.policy.allow.allow + want_result: true + + - note: "Azure Policy Deny Test - HTTP Traffic" + data: {} + input: + type: "Microsoft.Storage/storageAccounts" + name: "insecurestorage" + location: "West US" + kind: "Storage" + properties: + supportsHttpsTrafficOnly: false + minimumTlsVersion: "TLS1_0" + modules: + - | + package azure.policy.deny + + import rego.v1 + + __target__ := "target.tests.azure_policy" + + deny := { + "message": "HTTPS traffic must be enabled" + } if { + input.type == "Microsoft.Storage/storageAccounts" + input.properties.supportsHttpsTrafficOnly == false + } + query: data.azure.policy.deny.deny + want_result: + message: "HTTPS traffic must be enabled" + + - note: "Azure Policy Invalid Resource Type" + data: {} + input: + type: "Microsoft.UnknownService/unknownResource" + name: "test" + modules: + - | + package azure.policy.invalid + + import rego.v1 + + __target__ := "target.tests.azure_policy" + + default allow := false + + allow if { + input.type == "Microsoft.Storage/storageAccounts" + input.properties.supportsHttpsTrafficOnly == true + } + query: data.azure.policy.invalid.allow + want_result: false diff --git a/tests/interpreter/cases/target/basic.yaml b/tests/interpreter/cases/target/basic.yaml new file mode 100644 index 00000000..6c9f4de1 --- /dev/null +++ b/tests/interpreter/cases/target/basic.yaml @@ -0,0 +1,682 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +cases: + - note: "target/valid_target" + data: {} + input: {"name": "valid"} + modules: + - | + package test.allow + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + default allow := false + + allow if { + input.name == "valid" + } + query: data.test.allow.allow + want_result: true + + - note: "target/missing_assignment_operator" + data: {} + modules: + - | + package test.missing_assignment + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + allow if { + input.name == "valid" + } + query: data.test.missing_assignment.allow + want_result: "#undefined" + + - note: "target/effect_validation_undefined_result" + data: {} + input: {"name": "invalid"} + modules: + - | + package test.undefined + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + # No default, rule doesn't match, so allow is undefined + allow if { + input.name == "valid" + } + query: data.test.undefined.allow + want_result: "#undefined" + + - note: "target/target_resolution_timing" + data: {} + input: {"name": "valid"} + modules: + - | + package test + import rego.v1 + + __target__ "target.tests.sample_test_target" + + allow if { + input.name == "valid" + } + query: data.test.allow + error: "expected ':=' after __target__" + + - note: "target/missing_string_literal" + data: {} + modules: + - | + package test + + import rego.v1 + + __target__ := 123 + + allow if { + input.name == "valid" + } + query: data.test.allow + error: "expected string literal" + + - note: "target/invalid_string_format" + data: {} + modules: + - | + package test + + import rego.v1 + + __target__ := "unterminated string + + allow if { + input.name == "valid" + } + query: data.test.allow + error: "unterminated string" + + - note: "target/nonexistent_target" + data: {} + input: {"name": "valid"} + modules: + - | + package test + + import rego.v1 + + __target__ := "nonexistent.target.name" + + allow if { + input.name == "valid" + } + query: data.test.allow + error: "Target 'nonexistent.target.name' not found in registry" + + - note: "target/target_before_imports" + data: {} + modules: + - | + package test + + __target__ := "target.tests.sample_test_target" + + import rego.v1 + + allow if { + input.name == "valid" + } + query: data.test.allow + error: "unexpected keyword `import`" + + - note: "target/target_after_rule" + data: {} + input: {"name": "valid"} + modules: + - | + package test + + import rego.v1 + + allow if { + input.name == "valid" + } + + __target__ := "target.tests.sample_test_target" + query: data.test.allow + error: "__target__ must be defined before any rules" + + - note: "target/multiple_modules_same_target" + data: {} + input: {"name": "valid"} + modules: + - | + package test.module1 + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + allow if { + input.name == "valid" + } + - | + package test.module2 + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + deny if { + input.name == "invalid" + } + query: data.test.module1.allow + error: "Modules with target 'target.tests.sample_test_target' have different packages: 'test.module1' and 'test.module2'" + + - note: "target/multiple_modules_same_target_same_package" + data: {} + input: {"name": "valid"} + modules: + - | + package test.shared + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + allow if { + input.name == "valid" + } + - | + package test.shared + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + allow if { + input.name == "invalid" + } + query: data.test.shared.allow + want_result: true + + - note: "target/multiple_modules_different_targets" + data: {} + input: {"name": "valid"} + modules: + - | + package test.module1 + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + allow if { + input.name == "valid" + } + - | + package test.module2 + + import rego.v1 + + __target__ := "target.tests.azure_compute" + + deny if { + input.name == "invalid" + } + query: data.test.module1.allow + error: "Multiple different targets specified: 'target.tests.sample_test_target' and 'target.tests.azure_compute'" + + - note: "target/multiple_different_effects_same_package" + data: {} + input: {"name": "valid"} + modules: + - | + package test.multieffect + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + allow if { + input.name == "valid" + } + + deny if { + input.name == "invalid" + } + query: data.test.multieffect.allow + error: "Multiple effects have rules defined for target 'target.tests.sample_test_target': allow, deny. Only one effect should have rules defined in package 'test.multieffect'" + + - note: "target/no_effect_rules_defined" + data: {} + input: {"name": "valid"} + modules: + - | + package test.noeffects + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + helper_function(x) if { + x == "valid" + } + query: data.test.noeffects.helper_function("valid") + error: "Target 'target.tests.sample_test_target' requires a rule with name allow, deny or test_effect in package 'test.noeffects'" + + - note: "target/valid_deny_effect" + data: {} + input: {"name": "invalid"} + modules: + - | + package test.deny + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + default deny := false + + deny if { + input.name == "invalid" + } + query: data.test.deny.deny + want_result: true + + - note: "target/valid_test_effect" + data: {} + input: {"level": "warning", "message": "test message"} + modules: + - | + package test.custom + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + test_effect := { + "level": input.level, + "message": input.message + } if { + input.level + input.message + } + query: data.test.custom.test_effect + want_result: {"level": "warning", "message": "test message"} + + - note: "target/multiple_rules_same_effect_same_module" + data: {} + input: {"name": "valid", "role": "admin"} + modules: + - | + package test.multirules + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + # Multiple rules for the same effect in the same module + allow if { + input.name == "valid" + } + + allow if { + input.role == "admin" + } + query: data.test.multirules.allow + want_result: true + + - note: "target/multiple_rules_same_effect_different_modules" + data: {} + input: {"name": "valid", "role": "admin"} + modules: + - | + package test.distributed + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + allow if { + input.name == "valid" + } + - | + package test.distributed + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + allow if { + input.role == "admin" + } + query: data.test.distributed.allow + want_result: true + + - note: "target/effect_with_non_effect_rules_same_module" + data: {} + input: {"name": "valid"} + modules: + - | + package test.mixed + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + allow if { + input.name == "valid" + } + + # Non-effect helper rule + is_admin if { + input.role == "admin" + } + + # Another non-effect rule + helper_data := {"status": "active"} + query: data.test.mixed.allow + want_result: true + + - note: "target/effect_with_non_effect_rules_different_modules" + data: {} + input: {"name": "valid", "role": "admin"} + modules: + - | + package test.separate + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + allow if { + input.name == "valid" + } + - | + package test.separate + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + # Non-effect helper rules in different module + is_admin if { + input.role == "admin" + } + + user_data := {"type": "user", "active": true} + query: data.test.separate.allow + want_result: true + + - note: "target/effect_subpath_should_fail" + data: {} + input: {"name": "valid"} + modules: + - | + package test.subpath + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + # This creates a rule at data.test.subpath.allow.nested.rule + # which is a subpath of the expected effect path data.test.subpath.allow + allow.nested.rule if { + input.name == "valid" + } + query: data.test.subpath.allow.nested.rule + error: "Target 'target.tests.sample_test_target' requires a rule with name allow, deny or test_effect in package 'test.subpath'" + + - note: "target/multiple_subpath_rules_should_fail" + data: {} + input: {"name": "valid"} + modules: + - | + package test.multisubpath + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + # Multiple subpath rules under allow + allow.users.admin if { + input.name == "valid" + } + + allow.users.guest if { + input.name == "guest" + } + query: data.test.multisubpath.allow.users.admin + error: "Target 'target.tests.sample_test_target' requires a rule with name allow, deny or test_effect in package 'test.multisubpath'" + + - note: "target/effect_schema_validation_valid" + data: {} + input: {"name": "valid"} + modules: + - | + package test.schema_valid + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + allow if { + input.name == "valid" + } + query: data.test.schema_valid.allow + want_result: true + + - note: "target/effect_schema_validation_invalid_type" + data: {} + input: {"name": "valid"} + modules: + - | + package test.schema_invalid + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + # test_effect schema expects object with string properties, but we return a string + test_effect := "invalid_string_instead_of_object" if { + input.name == "valid" + } + query: data.test.schema_invalid.test_effect + error: "Type mismatch" + + - note: "target/effect_schema_validation_missing_required_field" + data: {} + input: {"name": "valid"} + modules: + - | + package test.schema_missing_field + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + # test_effect expects an object, but with invalid property types + test_effect := { + "level": 123, # should be string, not number + "message": "test message" + } if { + input.name == "valid" + } + query: data.test.schema_missing_field.test_effect + error: "Type mismatch" + + - note: "target/effect_schema_validation_complex_object" + data: {} + input: {"name": "valid", "details": {"severity": "high", "category": "security"}} + modules: + - | + package test.schema_complex + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + test_effect := { + "level": "error", + "message": "Security violation detected", + "details": input.details + } if { + input.name == "valid" + input.details.severity == "high" + } + query: data.test.schema_complex.test_effect + want_result: {"level": "error", "message": "Security violation detected", "details": {"severity": "high", "category": "security"}} + + - note: "target/effect_schema_validation_enum_constraint" + data: {} + input: {"name": "valid"} + modules: + - | + package test.schema_enum + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + # test_effect requires an object, but we return an array instead + test_effect := ["invalid", "array", "instead", "of", "object"] if { + input.name == "valid" + } + query: data.test.schema_enum.test_effect + error: "Type mismatch" + + - note: "target/multiple_targets_different_schemas" + data: {} + input: {"name": "valid"} + modules: + - | + package test.multi_target_a + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + allow if { + input.name == "valid" + } + - | + package test.multi_target_b + + import rego.v1 + + __target__ := "target.tests.azure_compute" + + deny if { + input.name == "invalid" + } + query: data.test.multi_target_a.allow + error: "Multiple different targets specified" + + - note: "target/effect_validation_with_default_value" + data: {} + input: {"name": "valid"} + modules: + - | + package test.with_default + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + default allow := false + + allow if { + input.name == "valid" + } + query: data.test.with_default.allow + want_result: true + + - note: "target/effect_validation_undefined_result" + data: {} + input: {"name": "invalid"} + modules: + - | + package test.undefined + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + # No default, rule doesn't match, so allow is undefined + allow if { + input.name == "valid" + } + query: data.test.undefined.allow + want_result: "#undefined" + + - note: "target/target_resolution_timing" + data: {} + input: {"name": "valid"} + modules: + - | + package test.timing + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + # Rule defined after target - should work due to proper timing + allow if { + input.name == "valid" + } + + # Helper rule that's not an effect + helper := "test" if true + query: data.test.timing.allow + want_result: true + + - note: "target/nested_package_structure" + data: {} + input: {"name": "valid"} + modules: + - | + package test.nested.deep.structures + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + allow if { + input.name == "valid" + } + query: data.test.nested.deep.structures.allow + want_result: true + + - note: "target/package_validation_error_consistency" + data: {} + input: {"name": "valid"} + modules: + - | + package test.package_a + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + allow if { + input.name == "valid" + } + - | + package test.package_b + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + deny if { + input.name == "invalid" + } + query: data.test.package_a.allow + error: "Modules with target 'target.tests.sample_test_target' have different packages: 'test.package_a' and 'test.package_b'" diff --git a/tests/interpreter/cases/target/complex.yaml b/tests/interpreter/cases/target/complex.yaml new file mode 100644 index 00000000..063ed2d7 --- /dev/null +++ b/tests/interpreter/cases/target/complex.yaml @@ -0,0 +1,561 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +cases: + - note: "target/complex_nested_object_validation" + data: {} + input: {"user": {"name": "alice", "roles": ["admin", "user"], "metadata": {"department": "engineering", "level": 5}}} + modules: + - | + package policy.complex + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + test_effect := { + "level": "info", + "message": sprintf("User %s from %s has access", [input.user.name, input.user.metadata.department]), + "details": { + "user_roles": input.user.roles, + "access_level": input.user.metadata.level, + "timestamp": "2025-08-14T10:00:00Z" + } + } if { + "admin" in input.user.roles + input.user.metadata.level >= 3 + } + query: data.policy.complex.test_effect + want_result: { + "details": { + "access_level": 5, + "timestamp": "2025-08-14T10:00:00Z", + "user_roles": ["admin", "user"] + }, + "level": "info", + "message": "User alice from engineering has access" + } + + - note: "target/complex_conditional_logic_with_multiple_rules" + data: {} + input: {"resource": {"type": "document", "classification": "confidential", "owner": "alice"}, "requester": {"id": "bob", "clearance": "secret"}} + modules: + - | + package policy.access_control + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + default allow := false + + # Multiple complex rules for the same effect + allow if { + input.resource.classification == "public" + } + + allow if { + input.resource.owner == input.requester.id + } + + allow if { + input.resource.classification == "confidential" + input.requester.clearance in ["secret", "top_secret"] + count([role | role := input.requester.roles[_]; role == "analyst"]) > 0 + } + + allow if { + input.resource.type == "document" + input.requester.clearance == "top_secret" + } + query: data.policy.access_control.allow + want_result: false + + - note: "target/complex_data_manipulation_with_comprehensions" + data: { + "users": [ + {"id": "u1", "name": "alice", "department": "eng", "salary": 100000}, + {"id": "u2", "name": "bob", "department": "sales", "salary": 80000}, + {"id": "u3", "name": "charlie", "department": "eng", "salary": 120000} + ] + } + input: {"department_filter": "eng", "min_salary": 90000} + modules: + - | + package policy.hr_analysis + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + test_effect := { + "level": "info", + "message": sprintf("Found %d qualified users", [count(qualified_users)]), + "details": { + "qualified_users": qualified_users, + "avg_salary": avg_salary, + "total_budget": total_budget + } + } if { + count(qualified_users) > 0 + } + + qualified_users := [user | + user := data.users[_] + user.department == input.department_filter + user.salary >= input.min_salary + ] + + # Extract total_budget calculation to avoid scheduling error + total_budget := v if { + count(qualified_users) > 0 + v := sum([salary | + true + user := qualified_users[_] + salary := user.salary + ]) + } + + # Extract avg_salary calculation to avoid scheduling error + avg_salary := v if { + count(qualified_users) > 0 + v := sum([salary | + true + user := qualified_users[_] + salary := user.salary + ]) / count(qualified_users) + } + query: data.policy.hr_analysis.test_effect + want_result: { + "level": "info", + "message": "Found 2 qualified users", + "details": { + "qualified_users": [ + {"id": "u1", "name": "alice", "department": "eng", "salary": 100000}, + {"id": "u3", "name": "charlie", "department": "eng", "salary": 120000} + ], + "avg_salary": 110000, + "total_budget": 220000 + } + } + + - note: "target/corner_case_empty_collections" + data: {"empty_array": [], "empty_object": {}} + input: {"filters": []} + modules: + - | + package policy.empty_collections + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + allow if { + count(data.empty_array) == 0 + count(object.keys(data.empty_object)) == 0 + count(input.filters) == 0 + } + query: data.policy.empty_collections.allow + want_result: true + + - note: "target/corner_case_null_and_undefined_handling" + data: {"nullable_field": null} + input: {"optional_field": null} + modules: + - | + package policy.null_handling + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + default test_effect := {} + + test_effect := { + "level": "warning", + "message": "Handling null values correctly" + } if { + data.nullable_field == null + input.optional_field == null + # Check that missing_field is undefined by ensuring it's not present + not "missing_field" in object.keys(data) + } + query: data.policy.null_handling.test_effect + want_result: { + "level": "warning", + "message": "Handling null values correctly" + } + + - note: "target/corner_case_deeply_nested_structures" + data: {} + input: { + "request": { + "metadata": { + "auth": { + "user": { + "profile": { + "permissions": { + "read": ["doc1", "doc2"], + "write": ["doc1"] + } + } + } + } + } + } + } + modules: + - | + package policy.deep_nesting + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + allow if { + permissions := input.request.metadata.auth.user.profile.permissions + "doc1" in permissions.read + "doc1" in permissions.write + } + query: data.policy.deep_nesting.allow + want_result: true + + - note: "target/corner_case_unicode_and_special_characters" + data: {} + input: { + "user": "測試用戶", + "message": "Hello, 世界! 🌍", + "special_chars": "!@#$%^&*()_+-=[]{}|;':\",./<>?" + } + modules: + - | + package policy.unicode_handling + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + test_effect := { + "level": "info", + "message": sprintf("Processing for user: %s", [input.user]) + } if { + contains(input.message, "世界") + startswith(input.special_chars, "!") + } + query: data.policy.unicode_handling.test_effect + want_result: { + "level": "info", + "message": "Processing for user: 測試用戶" + } + + - note: "target/corner_case_large_numbers_and_precision" + data: {} + input: { + "large_int": 9223372036854775807, + "small_float": 0.000000000001, + "large_float": 1.7976931348623157e+308 + } + modules: + - | + package policy.numeric_precision + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + allow if { + input.large_int > 9000000000000000000 + input.small_float < 0.001 + input.large_float > 1e100 + } + query: data.policy.numeric_precision.allow + want_result: true + + - note: "target/corner_case_circular_references_in_data" + data: { + "users": { + "alice": {"id": "alice", "manager": "bob"}, + "bob": {"id": "bob", "manager": "charlie"}, + "charlie": {"id": "charlie", "manager": "alice"} + } + } + input: {"check_user": "alice"} + modules: + - | + package policy.circular_refs + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + # Detect circular management chain + deny if { + has_circular_management(input.check_user, set()) + } + + has_circular_management(user_id, visited) if { + user_id in visited + } + + has_circular_management(user_id, visited) if { + not user_id in visited + manager := data.users[user_id].manager + manager != null + has_circular_management(manager, visited | {user_id}) + } + query: data.policy.circular_refs.deny + want_result: true + + - note: "target/complex_schema_validation_failure_detailed" + data: {} + input: {"name": "test"} + modules: + - | + package policy.schema_failure + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + # This should fail schema validation - wrong structure entirely + test_effect := { + "invalid_field": "should_not_exist", + "level": 123, # should be string + "message": ["array", "instead", "of", "string"], # should be string + "extra_nested": { + "deep": { + "structure": "not_allowed" + } + } + } if { + input.name == "test" + } + query: data.policy.schema_failure.test_effect + error: "Type mismatch" + + - note: "target/complex_multiple_modules_with_helper_functions" + data: {} + input: {"operation": "delete", "resource_id": "sensitive_doc", "user_role": "admin"} + modules: + - | + package policy.authorization + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + default allow := false + + # Reference helper functions from the same package + allow if { + is_admin(input.user_role) + is_allowed_operation(input.operation) + not is_sensitive_resource(input.resource_id) + } + - | + package policy.authorization + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + is_admin(role) if { + role == "admin" + } + + is_allowed_operation(op) if { + op in ["read", "write", "delete"] + } + + is_sensitive_resource(resource_id) if { + startswith(resource_id, "sensitive_") + } + query: data.policy.authorization.allow + want_result: false + + - note: "target/corner_case_very_long_strings" + data: {} + input: { + "long_string": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo." + } + modules: + - | + package policy.long_strings + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + test_effect := { + "level": "info", + "message": sprintf("String length: %d characters", [count(input.long_string)]) + } if { + count(input.long_string) > 500 + contains(input.long_string, "Lorem ipsum") + } + query: data.policy.long_strings.test_effect + want_result: { + "level": "info", + "message": "String length: 661 characters" + } + + - note: "target/complex_regex_pattern_matching" + data: {} + input: { + "emails": [ + "valid@example.com", + "also.valid+tag@domain.co.uk", + "invalid.email", + "another@valid-domain.org" + ] + } + modules: + - | + package policy.email_validation + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + test_effect := { + "level": "info", + "message": sprintf("Validated %d emails, %d valid", [count(input.emails), count(valid_emails)]) + } if { + count(valid_emails) > 0 + } + + valid_emails := [email | + email := input.emails[_] + regex.match(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`, email) + ] + query: data.policy.email_validation.test_effect + want_result: { + "level": "info", + "message": "Validated 4 emails, 3 valid" + } + + - note: "target/corner_case_recursive_data_structures" + data: { + "filesystem": { + "root": { + "type": "directory", + "children": { + "home": { + "type": "directory", + "children": { + "user": { + "type": "directory", + "children": { + "document.txt": {"type": "file", "size": 1024} + } + } + } + }, + "etc": { + "type": "directory", + "children": { + "config.ini": {"type": "file", "size": 512} + } + } + } + } + } + } + input: {"search_type": "file"} + modules: + - | + package policy.filesystem + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + test_effect := { + "level": "info", + "message": sprintf("Found %d files", [count(all_files)]) + } if { + count(all_files) > 0 + } + + all_files[path] := file if { + walk(data.filesystem, [path, file]) + file.type == input.search_type + } + query: data.policy.filesystem.test_effect + want_result: { + "level": "info", + "message": "Found 2 files" + } + + - note: "target/complex_error_propagation_and_recovery" + data: {} + input: {"values": [1, 2, 0, 4, 5]} + modules: + - | + package policy.error_handling + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + test_effect := { + "level": "warning", + "message": sprintf("Division results: %v", [safe_divisions]) + } if { + count(safe_divisions) > 0 + } + + safe_divisions := [result | + value := input.values[i] + value != 0 # Skip zero values to avoid division by zero + result := 100 / value + ] + query: data.policy.error_handling.test_effect + want_result: { + "level": "warning", + "message": "Division results: [100, 50, 25, 20]" + } + + - note: "target/corner_case_edge_conditions_with_sets" + data: {} + input: { + "set1": ["a", "b", "c"], + "set2": ["b", "c", "d"], + "set3": ["c", "d", "e"] + } + modules: + - | + package policy.set_operations + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + test_effect := { + "level": "info", + "message": "Set operations completed", + "details": { + "intersection_all": intersection_all, + "union_all": union_all, + "symmetric_diff": symmetric_diff + } + } if { + count(intersection_all) > 0 + } + + s1 := {x | x := input.set1[_]} + s2 := {x | x := input.set2[_]} + s3 := {x | x := input.set3[_]} + + intersection_all := s1 & s2 & s3 + union_all := s1 | s2 | s3 + symmetric_diff := (s1 | s2) - (s1 & s2) + query: data.policy.set_operations.test_effect + want_result: { + "level": "info", + "message": "Set operations completed", + "details": { + "intersection_all": { "set!": ["c"] }, + "union_all": { "set!": ["a", "b", "c", "d", "e"] }, + "symmetric_diff": { "set!": ["a", "d"] } + } + } diff --git a/tests/interpreter/cases/target/definitions/azure_compute.json b/tests/interpreter/cases/target/definitions/azure_compute.json new file mode 100644 index 00000000..1e1e78ce --- /dev/null +++ b/tests/interpreter/cases/target/definitions/azure_compute.json @@ -0,0 +1,47 @@ +{ + "name": "target.tests.azure_compute", + "description": "Azure compute resources target for testing", + "version": "1.0.0", + "resource_schema_selector": "type", + "resource_schemas": [ + { + "type": "object", + "properties": { + "type": { "const": "Microsoft.Compute/virtualMachines" }, + "name": { "type": "string" }, + "location": { "type": "string" }, + "properties": { + "type": "object", + "properties": { + "vmSize": { "type": "string" }, + "storageProfile": { + "type": "object", + "properties": { + "imageReference": { + "type": "object", + "properties": { + "publisher": { "type": "string" }, + "offer": { "type": "string" }, + "sku": { "type": "string" } + } + } + } + } + } + } + }, + "required": ["type", "name", "location"] + } + ], + "effects": { + "allow": { "type": "boolean" }, + "deny": { "type": "boolean" }, + "audit": { + "type": "object", + "properties": { + "level": { "enum": ["info", "warning", "error"] }, + "message": { "type": "string" } + } + } + } +} diff --git a/tests/interpreter/cases/target/definitions/azure_policy.json b/tests/interpreter/cases/target/definitions/azure_policy.json new file mode 100644 index 00000000..cb6126a2 --- /dev/null +++ b/tests/interpreter/cases/target/definitions/azure_policy.json @@ -0,0 +1,125 @@ +{ + "name": "target.tests.azure_policy", + "description": "Azure Policy target for comprehensive policy evaluation testing", + "version": "1.0.0", + "resource_schema_selector": "type", + "resource_schemas": [ + { + "type": "object", + "properties": { + "type": { "const": "Microsoft.Resources/subscriptions" }, + "subscriptionId": { "type": "string" }, + "tenantId": { "type": "string" }, + "displayName": { "type": "string" } + }, + "required": ["type", "subscriptionId"] + }, + { + "type": "object", + "properties": { + "type": { "const": "Microsoft.Storage/storageAccounts" }, + "name": { "type": "string" }, + "location": { "type": "string" }, + "kind": { "enum": ["Storage", "StorageV2", "BlobStorage", "FileStorage", "BlockBlobStorage"] }, + "properties": { + "type": "object", + "properties": { + "supportsHttpsTrafficOnly": { "type": "boolean" }, + "minimumTlsVersion": { "enum": ["TLS1_0", "TLS1_1", "TLS1_2"] }, + "allowBlobPublicAccess": { "type": "boolean" }, + "encryption": { + "type": "object", + "properties": { + "services": { + "type": "object", + "properties": { + "blob": { "type": "object", "properties": { "enabled": { "type": "boolean" } } }, + "file": { "type": "object", "properties": { "enabled": { "type": "boolean" } } } + } + } + } + } + } + }, + "tags": { "type": "object" } + }, + "required": ["type", "name", "location"] + }, + { + "type": "object", + "properties": { + "type": { "const": "Microsoft.Network/networkSecurityGroups" }, + "name": { "type": "string" }, + "location": { "type": "string" }, + "properties": { + "type": "object", + "properties": { + "securityRules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "properties": { + "type": "object", + "properties": { + "direction": { "enum": ["Inbound", "Outbound"] }, + "access": { "enum": ["Allow", "Deny"] }, + "protocol": { "enum": ["Tcp", "Udp", "*"] }, + "sourcePortRange": { "type": "string" }, + "destinationPortRange": { "type": "string" }, + "sourceAddressPrefix": { "type": "string" }, + "destinationAddressPrefix": { "type": "string" }, + "priority": { "type": "integer", "minimum": 100, "maximum": 4096 } + } + } + } + } + } + } + } + }, + "required": ["type", "name", "location"] + } + ], + "effects": { + "allow": { "type": "boolean" }, + "deny": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + }, + "audit": { + "type": "object", + "properties": { + "level": { "enum": ["info", "warning", "error"] }, + "message": { "type": "string" }, + "complianceState": { "enum": ["Compliant", "NonCompliant", "Unknown"] } + } + }, + "modify": { + "type": "object", + "properties": { + "operations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "operation": { "enum": ["add", "replace", "remove"] }, + "field": { "type": "string" }, + "value": { "type": "any" } + } + } + } + } + }, + "deployIfNotExists": { + "type": "object", + "properties": { + "template": { "type": "object" }, + "parameters": { "type": "object" } + } + } + } +} diff --git a/tests/interpreter/cases/target/definitions/complex_target.json b/tests/interpreter/cases/target/definitions/complex_target.json new file mode 100644 index 00000000..8b135e45 --- /dev/null +++ b/tests/interpreter/cases/target/definitions/complex_target.json @@ -0,0 +1,195 @@ +{ + "name": "target.tests.complex_target", + "description": "A complex target for testing advanced features including discriminated unions", + "version": "2.0.0", + "resource_schema_selector": "resourceType", + "resource_schemas": [ + { + "type": "object", + "properties": { + "name": { "type": "string" }, + "resourceType": { "const": "compute" }, + "spec": { + "type": "object", + "properties": { + "cpu": { "type": "integer", "minimum": 1, "maximum": 64 }, + "memory": { "type": "string", "pattern": "^[0-9]+[GM]i$" } + }, + "required": ["cpu", "memory"] + } + }, + "required": ["name", "resourceType", "spec"] + }, + { + "type": "object", + "properties": { + "name": { "type": "string" }, + "resourceType": { "const": "storage" }, + "spec": { + "type": "object", + "properties": { + "size": { "type": "string", "pattern": "^[0-9]+[GTM]i$" }, + "type": { "enum": ["ssd", "hdd", "nvme"] } + }, + "required": ["size", "type"] + } + }, + "required": ["name", "resourceType", "spec"] + }, + { + "type": "object", + "properties": { + "name": { "type": "string" }, + "resourceType": { "type": "string" }, + "spec": { "type": "object" } + }, + "required": ["name"], + "additionalProperties": true + } + ], + "effects": { + "allow": { "type": "boolean" }, + "deny": { "type": "boolean" }, + "audit": { + "type": "object", + "properties": { + "action": { "type": "string" }, + "severity": { "enum": ["low", "medium", "high", "critical"] }, + "details": { + "type": "object", + "properties": { + "eventType": { "type": "string" } + }, + "required": ["eventType"], + "allOf": [ + { + "if": { + "properties": { + "eventType": { "const": "access" } + } + }, + "then": { + "properties": { + "user": { "type": "string" }, + "resource": { "type": "string" }, + "timestamp": { "type": "string" } + }, + "required": ["user", "resource", "timestamp"] + } + }, + { + "if": { + "properties": { + "eventType": { "const": "modification" } + } + }, + "then": { + "properties": { + "user": { "type": "string" }, + "resource": { "type": "string" }, + "changes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { "type": "string" }, + "oldValue": { "type": "any" }, + "newValue": { "type": "any" } + }, + "required": ["field", "oldValue", "newValue"] + } + } + }, + "required": ["user", "resource", "changes"] + } + }, + { + "if": { + "properties": { + "eventType": { "const": "security" } + } + }, + "then": { + "properties": { + "threatLevel": { "enum": ["low", "medium", "high"] }, + "source": { "type": "string" }, + "indicators": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["threatLevel", "source", "indicators"] + } + } + ] + } + }, + "required": ["action", "severity", "details"] + }, + "remediate": { + "type": "object", + "properties": { + "actionType": { "type": "string" }, + "config": { + "type": "object", + "properties": { + "operation": { "type": "string" } + }, + "required": ["operation"], + "allOf": [ + { + "if": { + "properties": { + "operation": { "const": "quarantine" } + } + }, + "then": { + "properties": { + "duration": { "type": "string", "pattern": "^[0-9]+[hmd]$" }, + "reason": { "type": "string" } + }, + "required": ["duration", "reason"] + } + }, + { + "if": { + "properties": { + "operation": { "const": "scale" } + } + }, + "then": { + "properties": { + "targetSize": { "type": "integer", "minimum": 0, "maximum": 100 }, + "metric": { "enum": ["cpu", "memory", "requests"] } + }, + "required": ["targetSize", "metric"] + } + }, + { + "if": { + "properties": { + "operation": { "const": "replace" } + } + }, + "then": { + "properties": { + "newResource": { + "type": "object", + "properties": { + "type": { "type": "string" }, + "spec": { "type": "object" } + }, + "required": ["type", "spec"] + }, + "preserveData": { "type": "boolean" } + }, + "required": ["newResource", "preserveData"] + } + } + ] + } + }, + "required": ["actionType", "config"] + } + } +} diff --git a/tests/interpreter/cases/target/definitions/msgraph.json b/tests/interpreter/cases/target/definitions/msgraph.json new file mode 100644 index 00000000..3a2c952f --- /dev/null +++ b/tests/interpreter/cases/target/definitions/msgraph.json @@ -0,0 +1,189 @@ +{ + "name": "target.tests.msgraph", + "description": "Microsoft Graph API target for identity and access management testing", + "version": "1.0.0", + "resource_schema_selector": "@odata.type", + "resource_schemas": [ + { + "type": "object", + "properties": { + "@odata.type": { "const": "#microsoft.graph.user" }, + "id": { "type": "string" }, + "userPrincipalName": { "type": "string" }, + "displayName": { "type": "string" }, + "givenName": { "type": "string" }, + "surname": { "type": "string" }, + "mail": { "type": "string" }, + "jobTitle": { "type": "string" }, + "department": { "type": "string" }, + "accountEnabled": { "type": "boolean" }, + "userType": { "enum": ["Member", "Guest"] }, + "assignedLicenses": { + "type": "array", + "items": { + "type": "object", + "properties": { + "skuId": { "type": "string" }, + "disabledPlans": { "type": "array", "items": { "type": "string" } } + } + } + }, + "signInActivity": { + "type": "object", + "properties": { + "lastSignInDateTime": { "type": "string" }, + "lastNonInteractiveSignInDateTime": { "type": "string" } + } + } + }, + "required": ["@odata.type", "id", "userPrincipalName"] + }, + { + "type": "object", + "properties": { + "@odata.type": { "const": "#microsoft.graph.group" }, + "id": { "type": "string" }, + "displayName": { "type": "string" }, + "description": { "type": "string" }, + "groupTypes": { "type": "array", "items": { "type": "string" } }, + "securityEnabled": { "type": "boolean" }, + "mailEnabled": { "type": "boolean" }, + "mail": { "type": "string" }, + "visibility": { "enum": ["Public", "Private", "HiddenMembership"] }, + "members": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "@odata.type": { "type": "string" } + } + } + } + }, + "required": ["@odata.type", "id", "displayName"] + }, + { + "type": "object", + "properties": { + "@odata.type": { "const": "#microsoft.graph.application" }, + "id": { "type": "string" }, + "appId": { "type": "string" }, + "displayName": { "type": "string" }, + "publisherDomain": { "type": "string" }, + "signInAudience": { "enum": ["AzureADMyOrg", "AzureADMultipleOrgs", "AzureADandPersonalMicrosoftAccount", "PersonalMicrosoftAccount"] }, + "requiredResourceAccess": { + "type": "array", + "items": { + "type": "object", + "properties": { + "resourceAppId": { "type": "string" }, + "resourceAccess": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "type": { "enum": ["Scope", "Role"] } + } + } + } + } + } + }, + "web": { + "type": "object", + "properties": { + "redirectUris": { "type": "array", "items": { "type": "string" } }, + "implicitGrantSettings": { + "type": "object", + "properties": { + "enableAccessTokenIssuance": { "type": "boolean" }, + "enableIdTokenIssuance": { "type": "boolean" } + } + } + } + } + }, + "required": ["@odata.type", "id", "appId", "displayName"] + }, + { + "type": "object", + "properties": { + "@odata.type": { "const": "#microsoft.graph.conditionalAccessPolicy" }, + "id": { "type": "string" }, + "displayName": { "type": "string" }, + "state": { "enum": ["enabled", "disabled", "enabledForReportingButNotEnforced"] }, + "conditions": { + "type": "object", + "properties": { + "users": { + "type": "object", + "properties": { + "includeUsers": { "type": "array", "items": { "type": "string" } }, + "excludeUsers": { "type": "array", "items": { "type": "string" } }, + "includeGroups": { "type": "array", "items": { "type": "string" } }, + "excludeGroups": { "type": "array", "items": { "type": "string" } } + } + }, + "applications": { + "type": "object", + "properties": { + "includeApplications": { "type": "array", "items": { "type": "string" } }, + "excludeApplications": { "type": "array", "items": { "type": "string" } } + } + }, + "locations": { + "type": "object", + "properties": { + "includeLocations": { "type": "array", "items": { "type": "string" } }, + "excludeLocations": { "type": "array", "items": { "type": "string" } } + } + }, + "riskLevels": { "type": "array", "items": { "enum": ["low", "medium", "high", "none"] } } + } + }, + "grantControls": { + "type": "object", + "properties": { + "operator": { "enum": ["AND", "OR"] }, + "builtInControls": { "type": "array", "items": { "enum": ["block", "mfa", "compliantDevice", "domainJoinedDevice", "approvedApplication", "compliantApplication"] } } + } + } + }, + "required": ["@odata.type", "id", "displayName", "state"] + } + ], + "effects": { + "allow": { "type": "boolean" }, + "block": { + "type": "object", + "properties": { + "reason": { "type": "string" }, + "blockType": { "enum": ["signin", "access", "registration"] } + } + }, + "requireMfa": { + "type": "object", + "properties": { + "methods": { "type": "array", "items": { "enum": ["sms", "voice", "app", "oath"] } } + } + }, + "audit": { + "type": "object", + "properties": { + "level": { "enum": ["info", "warning", "error"] }, + "message": { "type": "string" }, + "category": { "enum": ["signin", "audit", "risk", "provisioning"] } + } + }, + "remediate": { + "type": "object", + "properties": { + "action": { "enum": ["disable", "enable", "reset", "notify"] }, + "target": { "type": "string" }, + "parameters": { "type": "object" } + } + } + } +} diff --git a/tests/interpreter/cases/target/definitions/no_default_schema_target.json b/tests/interpreter/cases/target/definitions/no_default_schema_target.json new file mode 100644 index 00000000..786e3dc1 --- /dev/null +++ b/tests/interpreter/cases/target/definitions/no_default_schema_target.json @@ -0,0 +1,21 @@ +{ + "name": "target.tests.no_default_schema_target", + "description": "A target without a default schema for testing missing default schema error", + "version": "1.0.0", + "resource_schema_selector": "type", + "resource_schemas": [ + { + "type": "object", + "properties": { + "name": { "type": "string" }, + "type": { "const": "specific_resource_type" }, + "value": { "type": "string" } + }, + "required": ["name", "type"] + } + ], + "effects": { + "allow": { "type": "boolean" }, + "deny": { "type": "boolean" } + } +} diff --git a/tests/interpreter/cases/target/definitions/sample_target.json b/tests/interpreter/cases/target/definitions/sample_target.json new file mode 100644 index 00000000..7d4a5d74 --- /dev/null +++ b/tests/interpreter/cases/target/definitions/sample_target.json @@ -0,0 +1,38 @@ +{ + "name": "target.tests.sample_test_target", + "description": "A sample target for testing target loading functionality", + "version": "1.0.0", + "resource_schema_selector": "type", + "resource_schemas": [ + { + "type": "object", + "properties": { + "name": { "type": "string" }, + "type": { "const": "test_resource" }, + "value": { "type": "string" } + }, + "required": ["name", "type"] + }, + { + "type": "object", + "properties": { + "name": { "type": "string" }, + "type": { "type": "string" }, + "value": { "type": "string" } + }, + "required": ["name"], + "additionalProperties": true + } + ], + "effects": { + "allow": { "type": "boolean" }, + "deny": { "type": "boolean" }, + "test_effect": { + "type": "object", + "properties": { + "level": { "type": "string" }, + "message": { "type": "string" } + } + } + } +} diff --git a/tests/interpreter/cases/target/msgraph.yaml b/tests/interpreter/cases/target/msgraph.yaml new file mode 100644 index 00000000..c61cd372 --- /dev/null +++ b/tests/interpreter/cases/target/msgraph.yaml @@ -0,0 +1,83 @@ +cases: + - note: "Microsoft Graph User Access Control - Allow Active User" + data: {} + input: + "@odata.type": "#microsoft.graph.user" + id: "12345678-1234-1234-1234-123456789012" + userPrincipalName: "john.doe@company.com" + displayName: "John Doe" + givenName: "John" + surname: "Doe" + mail: "john.doe@company.com" + jobTitle: "Software Engineer" + department: "Engineering" + accountEnabled: true + userType: "Member" + modules: + - | + package msgraph.user.allow + + import rego.v1 + + __target__ := "target.tests.msgraph" + + default allow := false + + allow if { + input["@odata.type"] == "#microsoft.graph.user" + input.accountEnabled == true + input.userType == "Member" + } + query: data.msgraph.user.allow.allow + want_result: true + + - note: "Microsoft Graph User Access Control - Block Disabled User" + data: {} + input: + "@odata.type": "#microsoft.graph.user" + id: "87654321-4321-4321-4321-210987654321" + userPrincipalName: "disabled.user@company.com" + displayName: "Disabled User" + accountEnabled: false + userType: "Member" + modules: + - | + package msgraph.user.block + + import rego.v1 + + __target__ := "target.tests.msgraph" + + block := { + "reason": "User account is disabled", + "blockType": "signin" + } if { + input["@odata.type"] == "#microsoft.graph.user" + input.accountEnabled == false + } + query: data.msgraph.user.block.block + want_result: + reason: "User account is disabled" + blockType: "signin" + + - note: "Microsoft Graph Invalid Resource Type" + data: {} + input: + "@odata.type": "#microsoft.graph.unknownResource" + id: "test" + modules: + - | + package msgraph.invalid + + import rego.v1 + + __target__ := "target.tests.msgraph" + + default allow := false + + allow if { + input["@odata.type"] == "#microsoft.graph.user" + input.accountEnabled == true + } + query: data.msgraph.invalid.allow + want_result: false diff --git a/tests/interpreter/cases/target/resource_type_inference.yaml b/tests/interpreter/cases/target/resource_type_inference.yaml new file mode 100644 index 00000000..0689865f --- /dev/null +++ b/tests/interpreter/cases/target/resource_type_inference.yaml @@ -0,0 +1,455 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for resource type inference functionality +# The infer_resource_type function looks for patterns like input. == "resource_type" +# in effect rules and builds a mapping of queries to their inferred resource types and schemas. + +cases: + - note: "target/infer_resource_type_basic_equality" + data: {} + input: {"type": "test_resource", "name": "example"} + modules: + - | + package policy.basic_inference + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + default allow := false + + # This should infer resource type "test_resource" from the equality check + allow if { + input.type == "test_resource" + input.name != "" + } + query: data.policy.basic_inference.allow + want_result: true + want_inferred_resource_types: ["test_resource"] + + - note: "target/infer_resource_type_multiple_equality_checks" + data: {} + input: {"type": "test_resource", "name": "example", "status": "active"} + modules: + - | + package policy.multiple_checks + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + default allow := false + + # Multiple conditions with resource type check + allow if { + input.type == "test_resource" + input.status == "active" + input.name != "" + } + query: data.policy.multiple_checks.allow + want_result: true + want_inferred_resource_types: ["test_resource"] + + - note: "target/infer_resource_type_complex_target_compute" + data: {} + input: {"resourceType": "compute", "name": "vm1", "spec": {"cpu": 4, "memory": "8Gi"}} + modules: + - | + package policy.complex_compute + + import rego.v1 + + __target__ := "target.tests.complex_target" + + default allow := false + + # Should infer "compute" resource type from complex target + allow if { + input.resourceType == "compute" + input.spec.cpu >= 2 + input.spec.memory + } + query: data.policy.complex_compute.allow + want_result: true + want_inferred_resource_types: ["compute"] + + - note: "target/infer_resource_type_complex_target_storage" + data: {} + input: {"resourceType": "storage", "name": "disk1", "spec": {"size": "100Gi", "type": "ssd"}} + modules: + - | + package policy.complex_storage + + import rego.v1 + + __target__ := "target.tests.complex_target" + + default deny := false + + # Should infer "storage" resource type from complex target + deny if { + input.resourceType == "storage" + input.spec.type == "hdd" # Deny HDDs + } + query: data.policy.complex_storage.deny + want_result: false + want_inferred_resource_types: ["storage"] + + - note: "target/infer_resource_type_default_schema_usage" + data: {} + input: {"resourceType": "unknown_type", "name": "mystery_resource"} + modules: + - | + package policy.default_schema + + import rego.v1 + + __target__ := "target.tests.complex_target" + + default allow := false + + # Should use default schema for unknown resource types + allow if { + input.resourceType == "unknown_type" + input.name != "" + } + query: data.policy.default_schema.allow + want_result: true + want_inferred_resource_types: ["unknown_type"] + + - note: "target/infer_resource_type_multiple_rules_same_effect" + data: {} + input: {"type": "test_resource", "name": "example"} + modules: + - | + package policy.multiple_rules + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + default allow := false + + # First rule with resource type check + allow if { + input.type == "test_resource" + input.name == "example" + } + + # Second rule with different resource type check (should also be inferred) + allow if { + input.type == "other_resource" + input.status == "approved" + } + query: data.policy.multiple_rules.allow + want_result: true + want_inferred_resource_types: ["test_resource", "other_resource"] # Should infer both types from different rules + + - note: "target/infer_resource_type_no_equality_check" + data: {} + input: {"name": "example", "status": "active"} + modules: + - | + package policy.no_type_check + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + default allow := false + + # No resource type equality check - should resolve to default schema + allow if { + input.name == "example" + input.status == "active" + } + query: data.policy.no_type_check.allow + want_result: true + want_inferred_resource_types: ["default"] # Should resolve to default schema + + - note: "target/infer_resource_type_wrong_equality_direction" + data: {} + input: {"type": "test_resource", "name": "example"} + modules: + - | + package policy.wrong_direction + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + default allow := false + + # Equality check in wrong direction (should still be detected) + allow if { + "test_resource" == input.type + input.name != "" + } + query: data.policy.wrong_direction.allow + want_result: true + want_inferred_resource_types: ["test_resource"] + + - note: "target/infer_resource_type_array_access_selector" + data: {} + input: {"metadata": {"type": "test_resource"}, "name": "example"} + modules: + - | + package policy.array_access + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + default allow := false + + # Using array-style access for the selector field + allow if { + input["type"] == "test_resource" + input.name != "" + } + query: data.policy.array_access.allow + want_result: false # This input doesn't have input.type, only input.metadata.type + want_inferred_resource_types: ["test_resource"] + + - note: "target/infer_resource_type_variable_in_equality" + data: {} + input: {"type": "test_resource", "name": "example"} + modules: + - | + package policy.variable_equality + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + default allow := false + + # Using a variable in the equality (resolves to default schema since not a literal string) + resource_type := "test_resource" + + allow if { + input.type == resource_type + input.name != "" + } + query: data.policy.variable_equality.allow + want_result: true + want_inferred_resource_types: ["default"] # Should resolve to default schema since not a literal string + + - note: "target/infer_resource_type_non_string_literal" + data: {} + input: {"priority": 5, "name": "example"} + modules: + - | + package policy.non_string + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + default allow := false + + # Non-string literal equality (resolves to default schema) + allow if { + input.priority == 5 + input.name != "" + } + query: data.policy.non_string.allow + want_result: true + want_inferred_resource_types: ["default"] # Should resolve to default schema + + - note: "target/infer_resource_type_not_first_statement" + data: {} + input: {"type": "test_resource", "name": "example", "status": "active"} + modules: + - | + package policy.not_first_statement + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + default allow := false + + # Resource type check is not the first statement (should still be inferred) + allow if { + input.name != "" + input.status == "active" + input.type == "test_resource" + } + query: data.policy.not_first_statement.allow + want_result: true + want_inferred_resource_types: ["test_resource"] + + - note: "target/infer_resource_type_nested_condition_not_inferred" + data: {} + input: {"type": "test_resource", "name": "example", "nested": {"resourceType": "compute"}} + modules: + - | + package policy.nested_condition + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + default allow := false + + # Nested condition with resource type check (should not be inferred at nested level) + allow if { + input.name == "example" + some condition + condition := input.nested.resourceType == "compute" + condition + } + query: data.policy.nested_condition.allow + want_result: true + want_inferred_resource_types: ["default"] # Should resolve to default schema (nested not inferred) + + - note: "target/infer_resource_type_nested_rule_not_inferred" + data: {} + input: {"type": "test_resource", "name": "example"} + modules: + - | + package policy.nested_rule + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + default allow := false + + # Helper rule with nested type check (should not be inferred from helper rules) + is_valid_resource(resource_type) if { + resource_type == "test_resource" + } + + # Main rule that uses helper (should resolve to default schema) + allow if { + input.name == "example" + is_valid_resource(input.type) + } + query: data.policy.nested_rule.allow + want_result: true + want_inferred_resource_types: ["default"] # Should resolve to default schema (helper rule not inferred) + + - note: "target/infer_resource_type_comprehension_not_inferred" + data: {} + input: {"type": "test_resource", "name": "example", "resources": [{"type": "compute"}, {"type": "storage"}]} + modules: + - | + package policy.comprehension + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + default allow := false + + # Type check inside comprehension (should not be inferred) + allow if { + input.name == "example" + valid_resources := [r | r := input.resources[_]; r.type == "compute"] + count(valid_resources) > 0 + } + query: data.policy.comprehension.allow + want_result: true + want_inferred_resource_types: ["default"] # Should resolve to default schema (comprehension not inferred) + + - note: "target/infer_resource_type_multiple_types_same_rule" + data: {} + input: {"type": "test_resource", "name": "example"} + modules: + - | + package policy.multiple_types_same_rule + + import rego.v1 + + __target__ := "target.tests.sample_test_target" + + default allow := false + + # Single rule with multiple resource type checks (should infer both) + allow if { + input.name == "example" + input.type == "test_resource" + input.type == "other_resource" # This will never match, but should still be inferred + } + query: data.policy.multiple_types_same_rule.allow + want_result: false # Will never match since input.type can't be both values + want_inferred_resource_types: ["test_resource", "other_resource"] + + - note: "target/infer_resource_type_multiple_types_different_rules" + data: {} + input: {"resourceType": "compute", "name": "vm1", "spec": {"cpu": 4}} + modules: + - | + package policy.multiple_types_different_rules + + import rego.v1 + + __target__ := "target.tests.complex_target" + + default allow := false + + # First rule checks for compute + allow if { + input.resourceType == "compute" + input.spec.cpu >= 2 + } + + # Second rule checks for storage + allow if { + input.resourceType == "storage" + input.spec.size + } + + # Third rule checks for network + allow if { + input.resourceType == "network" + input.spec.subnet + } + query: data.policy.multiple_types_different_rules.allow + want_result: true + want_inferred_resource_types: ["compute", "storage", "network"] + + - note: "target/infer_resource_type_invalid_schema_error" + data: {} + input: {"type": "invalid_resource", "name": "example"} + modules: + - | + package policy.invalid_schema + + import rego.v1 + + __target__ := "target.tests.nonexistent_target" + + default allow := false + + # Reference to a target that doesn't exist in the registry + allow if { + input.type == "test_resource" + input.name != "" + } + query: data.policy.invalid_schema.allow + error: "Target 'target.tests.nonexistent_target' not found in registry" + + - note: "target/infer_resource_type_missing_default_schema_error" + data: {} + input: {"name": "example", "status": "active"} + modules: + - | + package policy.missing_default_schema + + import rego.v1 + + __target__ := "target.tests.no_default_schema_target" + + default allow := false + + # No type check, should trigger missing default schema error + allow if { + input.name == "example" + input.status == "active" + } + query: data.policy.missing_default_schema.allow + error: "Missing default resource schema: Target 'target.tests.no_default_schema_target' has no default resource schema" \ No newline at end of file