use crate::{Cap, CapOutput, CapArg, ArgSource, ArgumentValidation};
use crate::media_spec::{resolve_media_urn, ResolvedMediaSpec};
use std::collections::HashSet;
use crate::profile_schema_registry::ProfileSchemaRegistry;
use serde_json::Value;
use std::fmt;
use std::sync::Arc;
#[derive(Debug, Clone)]
pub enum ValidationError {
UnknownCap {
cap_urn: String,
},
MissingRequiredArgument {
cap_urn: String,
argument_name: String,
},
UnknownArgument {
cap_urn: String,
argument_name: String,
},
InvalidArgumentType {
cap_urn: String,
argument_name: String,
expected_media_spec: String,
actual_value: Value,
schema_errors: Vec<String>,
},
ArgumentValidationFailed {
cap_urn: String,
argument_name: String,
validation_rule: String,
actual_value: Value,
},
MediaSpecValidationFailed {
cap_urn: String,
argument_name: String,
media_urn: String,
validation_rule: String,
actual_value: Value,
},
InvalidOutputType {
cap_urn: String,
expected_media_spec: String,
actual_value: Value,
schema_errors: Vec<String>,
},
OutputValidationFailed {
cap_urn: String,
validation_rule: String,
actual_value: Value,
},
OutputMediaSpecValidationFailed {
cap_urn: String,
media_urn: String,
validation_rule: String,
actual_value: Value,
},
InvalidCapSchema {
cap_urn: String,
issue: String,
},
TooManyArguments {
cap_urn: String,
max_expected: usize,
actual_count: usize,
},
JsonParseError {
cap_urn: String,
error: String,
},
SchemaValidationFailed {
cap_urn: String,
field_name: String,
schema_errors: String,
},
InvalidMediaSpec {
cap_urn: String,
field_name: String,
error: String,
},
UnresolvableMediaUrn {
cap_urn: String,
media_urn: String,
location: String,
},
}
impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ValidationError::UnknownCap { cap_urn } => {
write!(f, "Unknown cap '{}' - cap not registered or advertised", cap_urn)
}
ValidationError::MissingRequiredArgument { cap_urn, argument_name } => {
write!(f, "Cap '{}' requires argument '{}' but it was not provided", cap_urn, argument_name)
}
ValidationError::UnknownArgument { cap_urn, argument_name } => {
write!(f, "Cap '{}' does not accept argument '{}' - check capability definition for valid arguments", cap_urn, argument_name)
}
ValidationError::InvalidArgumentType { cap_urn, argument_name, expected_media_spec, actual_value, schema_errors } => {
write!(f, "Cap '{}' argument '{}' expects media_spec '{}' but validation failed for value {}: {}",
cap_urn, argument_name, expected_media_spec, actual_value, schema_errors.join(", "))
}
ValidationError::ArgumentValidationFailed { cap_urn, argument_name, validation_rule, actual_value } => {
write!(f, "Cap '{}' argument '{}' failed validation rule '{}' with value: {}",
cap_urn, argument_name, validation_rule, actual_value)
}
ValidationError::MediaSpecValidationFailed { cap_urn, argument_name, media_urn, validation_rule, actual_value } => {
write!(f, "Cap '{}' argument '{}' failed media spec '{}' validation rule '{}' with value: {}",
cap_urn, argument_name, media_urn, validation_rule, actual_value)
}
ValidationError::InvalidOutputType { cap_urn, expected_media_spec, actual_value, schema_errors } => {
write!(f, "Cap '{}' output expects media_spec '{}' but validation failed for value {}: {}",
cap_urn, expected_media_spec, actual_value, schema_errors.join(", "))
}
ValidationError::OutputValidationFailed { cap_urn, validation_rule, actual_value } => {
write!(f, "Cap '{}' output failed validation rule '{}' with value: {}",
cap_urn, validation_rule, actual_value)
}
ValidationError::OutputMediaSpecValidationFailed { cap_urn, media_urn, validation_rule, actual_value } => {
write!(f, "Cap '{}' output failed media spec '{}' validation rule '{}' with value: {}",
cap_urn, media_urn, validation_rule, actual_value)
}
ValidationError::InvalidCapSchema { cap_urn, issue } => {
write!(f, "Cap '{}' has invalid schema: {}", cap_urn, issue)
}
ValidationError::TooManyArguments { cap_urn, max_expected, actual_count } => {
write!(f, "Cap '{}' expects at most {} arguments but received {}",
cap_urn, max_expected, actual_count)
}
ValidationError::JsonParseError { cap_urn, error } => {
write!(f, "Cap '{}' JSON parsing failed: {}", cap_urn, error)
}
ValidationError::SchemaValidationFailed { cap_urn, field_name, schema_errors } => {
write!(f, "Cap '{}' schema validation failed for '{}': {}", cap_urn, field_name, schema_errors)
}
ValidationError::InvalidMediaSpec { cap_urn, field_name, error } => {
write!(f, "Cap '{}' has invalid media_spec for '{}': {}", cap_urn, field_name, error)
}
ValidationError::UnresolvableMediaUrn { cap_urn, media_urn, location } => {
write!(f, "Cap '{}' references unresolvable media URN '{}' in {}", cap_urn, media_urn, location)
}
}
}
}
impl std::error::Error for ValidationError {}
pub struct InputValidator {
schema_registry: Arc<ProfileSchemaRegistry>,
}
impl InputValidator {
pub fn new(schema_registry: Arc<ProfileSchemaRegistry>) -> Self {
Self { schema_registry }
}
pub async fn validate_positional_arguments(
&self,
cap: &Cap,
arguments: &[Value],
) -> Result<(), ValidationError> {
let cap_urn = cap.urn_string();
let args = cap.get_args();
let positional_args: Vec<&CapArg> = args.iter()
.filter(|arg| arg.sources.iter().any(|s| matches!(s, ArgSource::Position { .. })))
.collect();
let mut sorted_positional: Vec<(&CapArg, usize)> = positional_args.iter()
.filter_map(|arg| {
arg.sources.iter()
.find_map(|s| if let ArgSource::Position { position } = s { Some((*arg, *position)) } else { None })
})
.collect();
sorted_positional.sort_by_key(|(_, pos)| *pos);
if arguments.len() > sorted_positional.len() {
return Err(ValidationError::TooManyArguments {
cap_urn,
max_expected: sorted_positional.len(),
actual_count: arguments.len(),
});
}
for (index, (arg_def, _pos)) in sorted_positional.iter().enumerate() {
if index >= arguments.len() {
if arg_def.required {
return Err(ValidationError::MissingRequiredArgument {
cap_urn: cap_urn.clone(),
argument_name: arg_def.media_urn.clone(),
});
}
continue;
}
self.validate_single_argument(cap, arg_def, &arguments[index]).await?;
}
Ok(())
}
pub async fn validate_named_arguments(
&self,
cap: &Cap,
named_args: &[Value],
) -> Result<(), ValidationError> {
let cap_urn = cap.urn_string();
let args = cap.get_args();
let mut provided_args = std::collections::HashMap::new();
for arg in named_args {
if let Value::Object(map) = arg {
if let (Some(Value::String(name)), Some(value)) = (map.get("name"), map.get("value")) {
provided_args.insert(name.clone(), value.clone());
}
}
}
for arg_def in args {
let cli_flag = arg_def.sources.iter()
.find_map(|s| if let ArgSource::CliFlag { cli_flag } = s { Some(cli_flag.as_str()) } else { None });
if let Some(flag) = cli_flag {
if let Some(provided_value) = provided_args.get(flag) {
self.validate_single_argument(cap, arg_def, provided_value).await?;
} else if arg_def.required {
return Err(ValidationError::MissingRequiredArgument {
cap_urn: cap_urn.clone(),
argument_name: format!("{} (expected as named argument with flag {})", arg_def.media_urn, flag),
});
}
}
}
let known_flags: HashSet<String> = args.iter()
.flat_map(|arg| arg.sources.iter())
.filter_map(|s| if let ArgSource::CliFlag { cli_flag } = s { Some(cli_flag.clone()) } else { None })
.collect();
for provided_name in provided_args.keys() {
if !known_flags.contains(provided_name) {
return Err(ValidationError::UnknownArgument {
cap_urn: cap_urn.clone(),
argument_name: provided_name.clone(),
});
}
}
Ok(())
}
async fn validate_single_argument(
&self,
cap: &Cap,
arg_def: &CapArg,
value: &Value,
) -> Result<(), ValidationError> {
let resolved = self.validate_argument_type(cap, arg_def, value).await?;
if let Some(ref validation) = resolved.validation {
self.validate_media_spec_rules(cap, arg_def, &resolved, validation, value)?;
}
self.validate_argument_rules(cap, arg_def, value)?;
Ok(())
}
async fn validate_argument_type(
&self,
cap: &Cap,
arg_def: &CapArg,
value: &Value,
) -> Result<ResolvedMediaSpec, ValidationError> {
let cap_urn = cap.urn_string();
let media_specs = cap.get_media_specs();
let resolved = resolve_media_urn(&arg_def.media_urn, media_specs)
.map_err(|e| ValidationError::InvalidMediaSpec {
cap_urn: cap_urn.clone(),
field_name: arg_def.media_urn.clone(),
error: e.to_string(),
})?;
if resolved.is_binary() {
if !matches!(value, Value::String(_)) {
return Err(ValidationError::InvalidArgumentType {
cap_urn,
argument_name: arg_def.media_urn.clone(),
expected_media_spec: arg_def.media_urn.clone(),
actual_value: value.clone(),
schema_errors: vec!["Expected base64-encoded string for binary type".to_string()],
});
}
return Ok(resolved);
}
if let Some(ref schema) = resolved.schema {
if let Err(errors) = self.validate_with_local_schema(schema, value) {
return Err(ValidationError::InvalidArgumentType {
cap_urn,
argument_name: arg_def.media_urn.clone(),
expected_media_spec: arg_def.media_urn.clone(),
actual_value: value.clone(),
schema_errors: errors,
});
}
return Ok(resolved);
}
if let Some(ref profile) = resolved.profile_uri {
if let Err(errors) = self.schema_registry.validate(profile, value).await {
return Err(ValidationError::InvalidArgumentType {
cap_urn,
argument_name: arg_def.media_urn.clone(),
expected_media_spec: arg_def.media_urn.clone(),
actual_value: value.clone(),
schema_errors: errors,
});
}
}
Ok(resolved)
}
fn validate_media_spec_rules(
&self,
cap: &Cap,
arg_def: &CapArg,
resolved: &ResolvedMediaSpec,
validation: &ArgumentValidation,
value: &Value,
) -> Result<(), ValidationError> {
let cap_urn = cap.urn_string();
if let Some(min) = validation.min {
if let Some(num) = value.as_f64() {
if num < min {
return Err(ValidationError::MediaSpecValidationFailed {
cap_urn,
argument_name: arg_def.media_urn.clone(),
media_urn: resolved.media_urn.clone(),
validation_rule: format!("minimum value {}", min),
actual_value: value.clone(),
});
}
}
}
if let Some(max) = validation.max {
if let Some(num) = value.as_f64() {
if num > max {
return Err(ValidationError::MediaSpecValidationFailed {
cap_urn,
argument_name: arg_def.media_urn.clone(),
media_urn: resolved.media_urn.clone(),
validation_rule: format!("maximum value {}", max),
actual_value: value.clone(),
});
}
}
}
if let Some(min_length) = validation.min_length {
match (value.as_str(), value.as_array()) {
(Some(s), _) => {
if s.len() < min_length {
return Err(ValidationError::MediaSpecValidationFailed {
cap_urn,
argument_name: arg_def.media_urn.clone(),
media_urn: resolved.media_urn.clone(),
validation_rule: format!("minimum length {}", min_length),
actual_value: value.clone(),
});
}
},
(_, Some(arr)) => {
if arr.len() < min_length {
return Err(ValidationError::MediaSpecValidationFailed {
cap_urn,
argument_name: arg_def.media_urn.clone(),
media_urn: resolved.media_urn.clone(),
validation_rule: format!("minimum array length {}", min_length),
actual_value: value.clone(),
});
}
},
_ => {}
}
}
if let Some(max_length) = validation.max_length {
match (value.as_str(), value.as_array()) {
(Some(s), _) => {
if s.len() > max_length {
return Err(ValidationError::MediaSpecValidationFailed {
cap_urn,
argument_name: arg_def.media_urn.clone(),
media_urn: resolved.media_urn.clone(),
validation_rule: format!("maximum length {}", max_length),
actual_value: value.clone(),
});
}
},
(_, Some(arr)) => {
if arr.len() > max_length {
return Err(ValidationError::MediaSpecValidationFailed {
cap_urn,
argument_name: arg_def.media_urn.clone(),
media_urn: resolved.media_urn.clone(),
validation_rule: format!("maximum array length {}", max_length),
actual_value: value.clone(),
});
}
},
_ => {}
}
}
if let Some(pattern) = &validation.pattern {
if let Some(s) = value.as_str() {
let regex = regex::Regex::new(pattern)
.map_err(|e| ValidationError::InvalidCapSchema {
cap_urn: cap_urn.clone(),
issue: format!("Invalid regex pattern '{}' in media spec '{}': {}", pattern, resolved.media_urn, e),
})?;
if !regex.is_match(s) {
return Err(ValidationError::MediaSpecValidationFailed {
cap_urn,
argument_name: arg_def.media_urn.clone(),
media_urn: resolved.media_urn.clone(),
validation_rule: format!("pattern '{}'", pattern),
actual_value: value.clone(),
});
}
}
}
if let Some(allowed_values) = &validation.allowed_values {
if let Some(s) = value.as_str() {
if !allowed_values.contains(&s.to_string()) {
return Err(ValidationError::MediaSpecValidationFailed {
cap_urn,
argument_name: arg_def.media_urn.clone(),
media_urn: resolved.media_urn.clone(),
validation_rule: format!("allowed values: {:?}", allowed_values),
actual_value: value.clone(),
});
}
}
}
Ok(())
}
fn validate_with_local_schema(&self, schema: &Value, value: &Value) -> Result<(), Vec<String>> {
let compiled = match jsonschema::JSONSchema::compile(schema) {
Ok(c) => c,
Err(e) => return Err(vec![format!("Failed to compile schema: {}", e)]),
};
let result = compiled.validate(value);
match result {
Ok(_) => Ok(()),
Err(errors) => {
let error_strings: Vec<String> = errors
.map(|e| format!("{}: {}", e.instance_path, e))
.collect();
Err(error_strings)
}
}
}
fn validate_argument_rules(
&self,
cap: &Cap,
arg_def: &CapArg,
value: &Value,
) -> Result<(), ValidationError> {
let cap_urn = cap.urn_string();
let validation = &arg_def.validation;
if let Some(min) = validation.min {
if let Some(num) = value.as_f64() {
if num < min {
return Err(ValidationError::ArgumentValidationFailed {
cap_urn,
argument_name: arg_def.media_urn.clone(),
validation_rule: format!("minimum value {}", min),
actual_value: value.clone(),
});
}
}
}
if let Some(max) = validation.max {
if let Some(num) = value.as_f64() {
if num > max {
return Err(ValidationError::ArgumentValidationFailed {
cap_urn,
argument_name: arg_def.media_urn.clone(),
validation_rule: format!("maximum value {}", max),
actual_value: value.clone(),
});
}
}
}
if let Some(min_length) = validation.min_length {
match (value.as_str(), value.as_array()) {
(Some(s), _) => {
if s.len() < min_length {
return Err(ValidationError::ArgumentValidationFailed {
cap_urn,
argument_name: arg_def.media_urn.clone(),
validation_rule: format!("minimum length {}", min_length),
actual_value: value.clone(),
});
}
},
(_, Some(arr)) => {
if arr.len() < min_length {
return Err(ValidationError::ArgumentValidationFailed {
cap_urn,
argument_name: arg_def.media_urn.clone(),
validation_rule: format!("minimum array length {}", min_length),
actual_value: value.clone(),
});
}
},
_ => {}
}
}
if let Some(max_length) = validation.max_length {
match (value.as_str(), value.as_array()) {
(Some(s), _) => {
if s.len() > max_length {
return Err(ValidationError::ArgumentValidationFailed {
cap_urn,
argument_name: arg_def.media_urn.clone(),
validation_rule: format!("maximum length {}", max_length),
actual_value: value.clone(),
});
}
},
(_, Some(arr)) => {
if arr.len() > max_length {
return Err(ValidationError::ArgumentValidationFailed {
cap_urn,
argument_name: arg_def.media_urn.clone(),
validation_rule: format!("maximum array length {}", max_length),
actual_value: value.clone(),
});
}
},
_ => {}
}
}
if let Some(pattern) = &validation.pattern {
if let Some(s) = value.as_str() {
let regex = regex::Regex::new(pattern)
.map_err(|e| ValidationError::InvalidCapSchema {
cap_urn: cap_urn.clone(),
issue: format!("Invalid regex pattern '{}' in argument '{}': {}", pattern, arg_def.media_urn, e),
})?;
if !regex.is_match(s) {
return Err(ValidationError::ArgumentValidationFailed {
cap_urn,
argument_name: arg_def.media_urn.clone(),
validation_rule: format!("pattern '{}'", pattern),
actual_value: value.clone(),
});
}
}
}
if let Some(allowed_values) = &validation.allowed_values {
if let Some(s) = value.as_str() {
if !allowed_values.contains(&s.to_string()) {
return Err(ValidationError::ArgumentValidationFailed {
cap_urn,
argument_name: arg_def.media_urn.clone(),
validation_rule: format!("allowed values: {:?}", allowed_values),
actual_value: value.clone(),
});
}
}
}
Ok(())
}
}
pub struct OutputValidator {
schema_registry: Arc<ProfileSchemaRegistry>,
}
impl OutputValidator {
pub fn new(schema_registry: Arc<ProfileSchemaRegistry>) -> Self {
Self { schema_registry }
}
pub async fn validate_output(
&self,
cap: &Cap,
output: &Value,
) -> Result<(), ValidationError> {
let cap_urn = cap.urn_string();
let output_def = cap.get_output()
.ok_or_else(|| ValidationError::InvalidCapSchema {
cap_urn: cap_urn.clone(),
issue: "No output definition specified".to_string(),
})?;
let resolved = self.validate_output_type(cap, output_def, output).await?;
if let Some(ref validation) = resolved.validation {
self.validate_output_media_spec_rules(cap, &resolved, validation, output)?;
}
self.validate_output_rules(cap, output_def, output)?;
Ok(())
}
async fn validate_output_type(
&self,
cap: &Cap,
output_def: &CapOutput,
value: &Value,
) -> Result<ResolvedMediaSpec, ValidationError> {
let cap_urn = cap.urn_string();
let media_specs = cap.get_media_specs();
let resolved = resolve_media_urn(&output_def.media_urn, media_specs)
.map_err(|e| ValidationError::InvalidMediaSpec {
cap_urn: cap_urn.clone(),
field_name: "output".to_string(),
error: e.to_string(),
})?;
if resolved.is_binary() {
if !matches!(value, Value::String(_)) {
return Err(ValidationError::InvalidOutputType {
cap_urn,
expected_media_spec: output_def.media_urn.clone(),
actual_value: value.clone(),
schema_errors: vec!["Expected base64-encoded string for binary type".to_string()],
});
}
return Ok(resolved);
}
if let Some(ref schema) = resolved.schema {
if let Err(errors) = self.validate_with_local_schema(schema, value) {
return Err(ValidationError::InvalidOutputType {
cap_urn,
expected_media_spec: output_def.media_urn.clone(),
actual_value: value.clone(),
schema_errors: errors,
});
}
return Ok(resolved);
}
if let Some(ref profile) = resolved.profile_uri {
if let Err(errors) = self.schema_registry.validate(profile, value).await {
return Err(ValidationError::InvalidOutputType {
cap_urn,
expected_media_spec: output_def.media_urn.clone(),
actual_value: value.clone(),
schema_errors: errors,
});
}
}
Ok(resolved)
}
fn validate_output_media_spec_rules(
&self,
cap: &Cap,
resolved: &ResolvedMediaSpec,
validation: &ArgumentValidation,
value: &Value,
) -> Result<(), ValidationError> {
let cap_urn = cap.urn_string();
if let Some(min) = validation.min {
if let Some(num) = value.as_f64() {
if num < min {
return Err(ValidationError::OutputMediaSpecValidationFailed {
cap_urn,
media_urn: resolved.media_urn.clone(),
validation_rule: format!("minimum value {}", min),
actual_value: value.clone(),
});
}
}
}
if let Some(max) = validation.max {
if let Some(num) = value.as_f64() {
if num > max {
return Err(ValidationError::OutputMediaSpecValidationFailed {
cap_urn,
media_urn: resolved.media_urn.clone(),
validation_rule: format!("maximum value {}", max),
actual_value: value.clone(),
});
}
}
}
if let Some(min_length) = validation.min_length {
if let Some(s) = value.as_str() {
if s.len() < min_length {
return Err(ValidationError::OutputMediaSpecValidationFailed {
cap_urn,
media_urn: resolved.media_urn.clone(),
validation_rule: format!("minimum length {}", min_length),
actual_value: value.clone(),
});
}
}
}
if let Some(max_length) = validation.max_length {
if let Some(s) = value.as_str() {
if s.len() > max_length {
return Err(ValidationError::OutputMediaSpecValidationFailed {
cap_urn,
media_urn: resolved.media_urn.clone(),
validation_rule: format!("maximum length {}", max_length),
actual_value: value.clone(),
});
}
}
}
if let Some(pattern) = &validation.pattern {
if let Some(s) = value.as_str() {
let regex = regex::Regex::new(pattern)
.map_err(|e| ValidationError::InvalidCapSchema {
cap_urn: cap_urn.clone(),
issue: format!("Invalid regex pattern '{}' in media spec '{}': {}", pattern, resolved.media_urn, e),
})?;
if !regex.is_match(s) {
return Err(ValidationError::OutputMediaSpecValidationFailed {
cap_urn,
media_urn: resolved.media_urn.clone(),
validation_rule: format!("pattern '{}'", pattern),
actual_value: value.clone(),
});
}
}
}
if let Some(allowed_values) = &validation.allowed_values {
if let Some(s) = value.as_str() {
if !allowed_values.contains(&s.to_string()) {
return Err(ValidationError::OutputMediaSpecValidationFailed {
cap_urn,
media_urn: resolved.media_urn.clone(),
validation_rule: format!("allowed values: {:?}", allowed_values),
actual_value: value.clone(),
});
}
}
}
Ok(())
}
fn validate_with_local_schema(&self, schema: &Value, value: &Value) -> Result<(), Vec<String>> {
let compiled = match jsonschema::JSONSchema::compile(schema) {
Ok(c) => c,
Err(e) => return Err(vec![format!("Failed to compile schema: {}", e)]),
};
let result = compiled.validate(value);
match result {
Ok(_) => Ok(()),
Err(errors) => {
let error_strings: Vec<String> = errors
.map(|e| format!("{}: {}", e.instance_path, e))
.collect();
Err(error_strings)
}
}
}
fn validate_output_rules(
&self,
cap: &Cap,
output_def: &CapOutput,
value: &Value,
) -> Result<(), ValidationError> {
let cap_urn = cap.urn_string();
let validation = &output_def.validation;
if let Some(min) = validation.min {
if let Some(num) = value.as_f64() {
if num < min {
return Err(ValidationError::OutputValidationFailed {
cap_urn,
validation_rule: format!("minimum value {}", min),
actual_value: value.clone(),
});
}
}
}
if let Some(max) = validation.max {
if let Some(num) = value.as_f64() {
if num > max {
return Err(ValidationError::OutputValidationFailed {
cap_urn,
validation_rule: format!("maximum value {}", max),
actual_value: value.clone(),
});
}
}
}
if let Some(min_length) = validation.min_length {
if let Some(s) = value.as_str() {
if s.len() < min_length {
return Err(ValidationError::OutputValidationFailed {
cap_urn,
validation_rule: format!("minimum length {}", min_length),
actual_value: value.clone(),
});
}
}
}
if let Some(max_length) = validation.max_length {
if let Some(s) = value.as_str() {
if s.len() > max_length {
return Err(ValidationError::OutputValidationFailed {
cap_urn,
validation_rule: format!("maximum length {}", max_length),
actual_value: value.clone(),
});
}
}
}
if let Some(pattern) = &validation.pattern {
if let Some(s) = value.as_str() {
let regex = regex::Regex::new(pattern)
.map_err(|e| ValidationError::InvalidCapSchema {
cap_urn: cap_urn.clone(),
issue: format!("Invalid regex pattern '{}' in output: {}", pattern, e),
})?;
if !regex.is_match(s) {
return Err(ValidationError::OutputValidationFailed {
cap_urn,
validation_rule: format!("pattern '{}'", pattern),
actual_value: value.clone(),
});
}
}
}
if let Some(allowed_values) = &validation.allowed_values {
if let Some(s) = value.as_str() {
if !allowed_values.contains(&s.to_string()) {
return Err(ValidationError::OutputValidationFailed {
cap_urn,
validation_rule: format!("allowed values: {:?}", allowed_values),
actual_value: value.clone(),
});
}
}
}
Ok(())
}
}
pub struct CapValidator;
impl CapValidator {
pub fn validate_cap(cap: &Cap) -> Result<(), ValidationError> {
let cap_urn = cap.urn_string();
let media_specs = cap.get_media_specs();
let args = cap.get_args();
for arg in args {
if arg.required && arg.default_value.is_some() {
return Err(ValidationError::InvalidCapSchema {
cap_urn: cap_urn.clone(),
issue: format!("Required argument '{}' cannot have a default value", arg.media_urn),
});
}
}
let mut positions = std::collections::HashSet::new();
for arg in args {
for source in &arg.sources {
if let ArgSource::Position { position } = source {
if !positions.insert(*position) {
return Err(ValidationError::InvalidCapSchema {
cap_urn: cap_urn.clone(),
issue: format!("Duplicate argument position {} for argument '{}'", position, arg.media_urn),
});
}
}
}
}
let mut cli_flags = std::collections::HashSet::new();
for arg in args {
for source in &arg.sources {
if let ArgSource::CliFlag { cli_flag } = source {
if !cli_flag.is_empty() {
if !cli_flags.insert(cli_flag) {
return Err(ValidationError::InvalidCapSchema {
cap_urn: cap_urn.clone(),
issue: format!("Duplicate CLI flag '{}' for argument '{}'", cli_flag, arg.media_urn),
});
}
}
}
}
}
for arg in args {
resolve_media_urn(&arg.media_urn, media_specs)
.map_err(|e| ValidationError::InvalidMediaSpec {
cap_urn: cap_urn.clone(),
field_name: arg.media_urn.clone(),
error: e.to_string(),
})?;
}
if let Some(output) = cap.get_output() {
resolve_media_urn(&output.media_urn, media_specs)
.map_err(|e| ValidationError::InvalidMediaSpec {
cap_urn: cap_urn.clone(),
field_name: "output".to_string(),
error: e.to_string(),
})?;
}
Ok(())
}
}
pub const RESERVED_CLI_FLAGS: &[&str] = &["manifest", "--help", "--version", "-v", "-h"];
pub fn validate_cap_args(cap: &Cap) -> Result<(), ValidationError> {
let cap_urn = cap.urn_string();
let args = cap.get_args();
let mut media_urns = HashSet::new();
for arg in args {
if !media_urns.insert(&arg.media_urn) {
return Err(ValidationError::InvalidCapSchema {
cap_urn,
issue: format!("RULE1: Duplicate media_urn '{}'", arg.media_urn),
});
}
}
for arg in args {
if arg.sources.is_empty() {
return Err(ValidationError::InvalidCapSchema {
cap_urn,
issue: format!("RULE2: Argument '{}' has empty sources", arg.media_urn),
});
}
}
let mut stdin_urns: Vec<String> = Vec::new();
let mut positions: Vec<(usize, String)> = Vec::new();
let mut cli_flags: Vec<(String, String)> = Vec::new();
for arg in args {
let mut source_types = HashSet::new();
let mut has_position = false;
let mut has_cli_flag = false;
for source in &arg.sources {
let source_type = source.get_type();
if !source_types.insert(source_type) {
return Err(ValidationError::InvalidCapSchema {
cap_urn,
issue: format!("RULE4: Argument '{}' has duplicate source type '{}'", arg.media_urn, source_type),
});
}
match source {
ArgSource::Stdin { stdin } => {
stdin_urns.push(stdin.clone());
}
ArgSource::Position { position } => {
has_position = true;
positions.push((*position, arg.media_urn.clone()));
}
ArgSource::CliFlag { cli_flag } => {
has_cli_flag = true;
cli_flags.push((cli_flag.clone(), arg.media_urn.clone()));
if RESERVED_CLI_FLAGS.contains(&cli_flag.as_str()) {
return Err(ValidationError::InvalidCapSchema {
cap_urn,
issue: format!("RULE10: Argument '{}' uses reserved cli_flag '{}'", arg.media_urn, cli_flag),
});
}
}
}
}
if has_position && has_cli_flag {
return Err(ValidationError::InvalidCapSchema {
cap_urn,
issue: format!("RULE7: Argument '{}' has both position and cli_flag sources", arg.media_urn),
});
}
}
if stdin_urns.len() > 1 {
let first_stdin = &stdin_urns[0];
for stdin in &stdin_urns[1..] {
if stdin != first_stdin {
return Err(ValidationError::InvalidCapSchema {
cap_urn,
issue: format!("RULE3: Multiple args have different stdin media_urns: '{}' vs '{}'", first_stdin, stdin),
});
}
}
}
let mut position_set = HashSet::new();
for (position, media_urn) in &positions {
if !position_set.insert(*position) {
return Err(ValidationError::InvalidCapSchema {
cap_urn,
issue: format!("RULE5: Duplicate position {} in argument '{}'", position, media_urn),
});
}
}
if !positions.is_empty() {
let mut sorted_positions = positions.clone();
sorted_positions.sort_by_key(|(pos, _)| *pos);
for (i, (position, _)) in sorted_positions.iter().enumerate() {
if *position != i {
return Err(ValidationError::InvalidCapSchema {
cap_urn,
issue: format!("RULE6: Position gap - expected {} but found {}", i, position),
});
}
}
}
let mut flag_set = HashSet::new();
for (flag, media_urn) in &cli_flags {
if !flag_set.insert(flag) {
return Err(ValidationError::InvalidCapSchema {
cap_urn,
issue: format!("RULE9: Duplicate cli_flag '{}' in argument '{}'", flag, media_urn),
});
}
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct SchemaValidator {
caps: std::collections::HashMap<String, Cap>,
}
impl SchemaValidator {
pub fn new() -> Self {
Self {
caps: std::collections::HashMap::new(),
}
}
pub fn register_cap(&mut self, cap: Cap) {
let urn = cap.urn_string();
self.caps.insert(urn, cap);
}
pub fn get_cap(&self, cap_urn: &str) -> Option<&Cap> {
self.caps.get(cap_urn)
}
pub async fn validate_inputs(
&self,
cap_urn: &str,
arguments: &[serde_json::Value],
schema_registry: Arc<ProfileSchemaRegistry>,
) -> Result<(), ValidationError> {
let cap = self.get_cap(cap_urn)
.ok_or_else(|| ValidationError::UnknownCap {
cap_urn: cap_urn.to_string(),
})?;
let validator = InputValidator::new(schema_registry);
validator.validate_positional_arguments(cap, arguments).await
}
pub async fn validate_output(
&self,
cap_urn: &str,
output: &serde_json::Value,
schema_registry: Arc<ProfileSchemaRegistry>,
) -> Result<(), ValidationError> {
let cap = self.get_cap(cap_urn)
.ok_or_else(|| ValidationError::UnknownCap {
cap_urn: cap_urn.to_string(),
})?;
let validator = OutputValidator::new(schema_registry);
validator.validate_output(cap, output).await
}
pub fn validate_cap_schema(
&self,
cap: &Cap,
) -> Result<(), ValidationError> {
CapValidator::validate_cap(cap)
}
}
impl Default for SchemaValidator {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::CapUrn;
use crate::standard::media::{MEDIA_STRING, MEDIA_INTEGER};
use serde_json::json;
fn test_urn(tags: &str) -> String {
format!("cap:in=media:void;out=media:object;{}", tags)
}
#[tokio::test]
async fn test_input_validation_success() {
let schema_registry = Arc::new(ProfileSchemaRegistry::new().await.unwrap());
let validator = InputValidator::new(schema_registry);
let urn = CapUrn::from_string(&test_urn("type=test;op=cap")).unwrap();
let mut cap = Cap::new(urn, "Test Capability".to_string(), "test-command".to_string());
let arg = CapArg::new(
MEDIA_STRING,
true,
vec![ArgSource::Position { position: 0 }],
);
cap.add_arg(arg);
let input_args = vec![json!("/path/to/file.txt")];
assert!(validator.validate_positional_arguments(&cap, &input_args).await.is_ok());
}
#[tokio::test]
async fn test_input_validation_missing_required() {
let schema_registry = Arc::new(ProfileSchemaRegistry::new().await.unwrap());
let validator = InputValidator::new(schema_registry);
let urn = CapUrn::from_string(&test_urn("type=test;op=cap")).unwrap();
let mut cap = Cap::new(urn, "Test Capability".to_string(), "test-command".to_string());
let arg = CapArg::new(
MEDIA_STRING,
true,
vec![ArgSource::Position { position: 0 }],
);
cap.add_arg(arg);
let input_args = vec![];
let result = validator.validate_positional_arguments(&cap, &input_args).await;
assert!(result.is_err());
if let Err(ValidationError::MissingRequiredArgument { argument_name, .. }) = result {
assert_eq!(argument_name, MEDIA_STRING);
} else {
panic!("Expected MissingRequiredArgument error");
}
}
#[tokio::test]
async fn test_input_validation_wrong_type() {
let schema_registry = Arc::new(ProfileSchemaRegistry::new().await.unwrap());
let validator = InputValidator::new(schema_registry);
let urn = CapUrn::from_string(&test_urn("type=test;op=cap")).unwrap();
let mut cap = Cap::new(urn, "Test Capability".to_string(), "test-command".to_string());
let arg = CapArg::new(
MEDIA_INTEGER,
true,
vec![ArgSource::Position { position: 0 }],
);
cap.add_arg(arg);
let input_args = vec![json!("not_a_number")];
let result = validator.validate_positional_arguments(&cap, &input_args).await;
assert!(result.is_err());
if let Err(ValidationError::InvalidArgumentType { .. }) = result {
} else {
panic!("Expected InvalidArgumentType error");
}
}
}
pub async fn validate_cap_arguments(
registry: &crate::registry::CapRegistry,
schema_registry: Arc<ProfileSchemaRegistry>,
cap_urn: &str,
arguments: &[Value],
) -> Result<(), ValidationError> {
let canonical_cap = registry.get_cap(cap_urn).await
.map_err(|_| ValidationError::UnknownCap { cap_urn: cap_urn.to_string() })?;
let validator = InputValidator::new(schema_registry);
validator.validate_positional_arguments(&canonical_cap, arguments).await
}
pub async fn validate_cap_output(
registry: &crate::registry::CapRegistry,
schema_registry: Arc<ProfileSchemaRegistry>,
cap_urn: &str,
output: &Value,
) -> Result<(), ValidationError> {
let canonical_cap = registry.get_cap(cap_urn).await
.map_err(|_| ValidationError::UnknownCap { cap_urn: cap_urn.to_string() })?;
let validator = OutputValidator::new(schema_registry);
validator.validate_output(&canonical_cap, output).await
}
pub async fn validate_cap_canonical(registry: &crate::registry::CapRegistry, cap: &Cap) -> Result<(), ValidationError> {
registry.validate_cap(cap).await
.map_err(|e| ValidationError::InvalidCapSchema {
cap_urn: cap.urn_string(),
issue: e.to_string(),
})
}
pub async fn validate_media_urn_references(
cap: &Cap,
registry: Option<&crate::media_registry::MediaUrnRegistry>,
) -> Result<(), ValidationError> {
let cap_urn = cap.urn_string();
let media_specs = cap.get_media_specs();
let mut urns_to_check: Vec<(String, String)> = Vec::new();
let in_spec = cap.urn.in_spec();
if in_spec != "*" {
urns_to_check.push(("in tag".to_string(), in_spec.to_string()));
}
let out_spec = cap.urn.out_spec();
if out_spec != "*" {
urns_to_check.push(("out tag".to_string(), out_spec.to_string()));
}
for arg in cap.get_args() {
urns_to_check.push((format!("argument '{}'", arg.media_urn), arg.media_urn.clone()));
}
if let Some(output) = cap.get_output() {
urns_to_check.push(("output".to_string(), output.media_urn.clone()));
}
for (location, media_urn) in urns_to_check {
if crate::media_spec::resolve_media_urn(&media_urn, media_specs).is_ok() {
continue;
}
if let Some(registry) = registry {
if registry.get_media_spec(&media_urn).await.is_ok() {
continue;
}
}
return Err(ValidationError::UnresolvableMediaUrn {
cap_urn: cap_urn.clone(),
media_urn,
location: location.to_string(),
});
}
Ok(())
}
pub fn validate_media_urn_references_sync(cap: &Cap) -> Result<(), ValidationError> {
let cap_urn = cap.urn_string();
let media_specs = cap.get_media_specs();
let in_spec = cap.urn.in_spec();
if in_spec != "*" {
crate::media_spec::resolve_media_urn(in_spec, media_specs)
.map_err(|_| ValidationError::UnresolvableMediaUrn {
cap_urn: cap_urn.clone(),
media_urn: in_spec.to_string(),
location: "in tag".to_string(),
})?;
}
let out_spec = cap.urn.out_spec();
if out_spec != "*" {
crate::media_spec::resolve_media_urn(out_spec, media_specs)
.map_err(|_| ValidationError::UnresolvableMediaUrn {
cap_urn: cap_urn.clone(),
media_urn: out_spec.to_string(),
location: "out tag".to_string(),
})?;
}
for arg in cap.get_args() {
crate::media_spec::resolve_media_urn(&arg.media_urn, media_specs)
.map_err(|_| ValidationError::UnresolvableMediaUrn {
cap_urn: cap_urn.clone(),
media_urn: arg.media_urn.clone(),
location: format!("argument '{}'", arg.media_urn),
})?;
}
if let Some(output) = cap.get_output() {
crate::media_spec::resolve_media_urn(&output.media_urn, media_specs)
.map_err(|_| ValidationError::UnresolvableMediaUrn {
cap_urn: cap_urn.clone(),
media_urn: output.media_urn.clone(),
location: "output".to_string(),
})?;
}
Ok(())
}