component-manifest 0.3.9

Manifest validation helpers for Greentic components
Documentation
use jsonschema::validator_for;
use regex::Regex;
use semver::VersionReq;
use serde_json::Value;

use crate::types::{
    CompiledExportSchema, ComponentExport, ComponentInfo, ComponentManifest, ManifestError,
    WitCompat,
};

pub struct ManifestValidator {
    capability_pattern: Regex,
    operation_pattern: Regex,
    secret_pattern: Regex,
}

impl Default for ManifestValidator {
    fn default() -> Self {
        Self::new()
    }
}

impl ManifestValidator {
    pub fn new() -> Self {
        Self {
            capability_pattern: Regex::new(r"^[a-z][a-z0-9_.:-]*$").expect("valid regex"),
            operation_pattern: Regex::new(r"^[a-z][a-z0-9_.:-]*$").expect("valid regex"),
            secret_pattern: Regex::new(r"^[A-Z0-9_][A-Z0-9_.:-]*$").expect("valid regex"),
        }
    }

    pub fn validate_value(&self, manifest_json: Value) -> Result<ComponentInfo, ManifestError> {
        let manifest = ComponentManifest::from_value(manifest_json.clone())?;
        self.validate_manifest(manifest, manifest_json)
    }

    pub fn validate_manifest(
        &self,
        manifest: ComponentManifest,
        raw: Value,
    ) -> Result<ComponentInfo, ManifestError> {
        if manifest.capabilities.is_empty() {
            return Err(ManifestError::MissingCapabilities);
        }
        for capability in &manifest.capabilities {
            capability.validate(&self.capability_pattern)?;
        }
        crate::types::ensure_unique(manifest.capabilities.iter().cloned(), |capability| {
            ManifestError::DuplicateCapability(capability.0)
        })?;

        if manifest.exports.is_empty() {
            return Err(ManifestError::MissingExports);
        }
        crate::types::ensure_unique(
            manifest
                .exports
                .iter()
                .map(|export| export.operation.clone()),
            ManifestError::DuplicateOperation,
        )?;
        for export in &manifest.exports {
            export.validate(&self.operation_pattern)?;
        }

        validate_wit_compat(&manifest.wit_compat)?;

        if !manifest.secrets.is_empty() {
            validate_secrets(&manifest.secrets, &self.secret_pattern)?;
        }

        let config_schema = validate_config_schema(&manifest.config_schema)?;
        let compiled_exports = manifest
            .exports
            .iter()
            .map(compile_export_schema)
            .collect::<Result<Vec<_>, _>>()?;

        Ok(ComponentInfo {
            name: manifest.name,
            description: manifest.description,
            capabilities: manifest.capabilities,
            exports: compiled_exports,
            config_schema,
            secrets: manifest.secrets,
            wit_compat: manifest.wit_compat,
            metadata: manifest.metadata,
            raw,
        })
    }
}

pub fn validate_config_schema(schema: &Value) -> Result<Value, ManifestError> {
    if !schema.is_object() {
        return Err(ManifestError::ConfigSchemaNotObject);
    }
    validator_for(schema).map_err(|err| ManifestError::InvalidConfigSchema(err.to_string()))?;
    Ok(schema.clone())
}

fn compile_export_schema(export: &ComponentExport) -> Result<CompiledExportSchema, ManifestError> {
    let input_schema = export
        .input_schema
        .as_ref()
        .map(|schema| parse_schema(schema, export, "input_schema"))
        .transpose()?;
    let output_schema = export
        .output_schema
        .as_ref()
        .map(|schema| parse_schema(schema, export, "output_schema"))
        .transpose()?;

    Ok(CompiledExportSchema {
        operation: export.operation.clone(),
        description: export.description.clone(),
        input_schema,
        output_schema,
    })
}

fn parse_schema(
    schema: &Value,
    export: &ComponentExport,
    field: &str,
) -> Result<Value, ManifestError> {
    if !schema.is_object() {
        return Err(ManifestError::InvalidExportSchema {
            operation: export.operation.clone(),
            reason: format!("{field} must be an object"),
        });
    }
    validator_for(schema).map_err(|err| ManifestError::InvalidExportSchema {
        operation: export.operation.clone(),
        reason: err.to_string(),
    })?;
    Ok(schema.clone())
}

fn validate_wit_compat(wit: &WitCompat) -> Result<(), ManifestError> {
    if wit.package != "greentic:component" {
        return Err(ManifestError::InvalidWitPackage {
            found: wit.package.clone(),
        });
    }
    VersionReq::parse(&wit.min).map_err(|source| ManifestError::InvalidVersionReq {
        field: "wit_compat.min",
        source,
    })?;
    if let Some(max) = &wit.max {
        VersionReq::parse(max).map_err(|source| ManifestError::InvalidVersionReq {
            field: "wit_compat.max",
            source,
        })?;
    }
    Ok(())
}

fn validate_secrets(secrets: &[String], pattern: &Regex) -> Result<(), ManifestError> {
    let mut seen = std::collections::HashSet::new();
    for secret in secrets {
        if secret.trim().is_empty() {
            return Err(ManifestError::EmptyField("secrets"));
        }
        if !pattern.is_match(secret) {
            return Err(ManifestError::InvalidSecret(secret.clone()));
        }
        if !seen.insert(secret.clone()) {
            return Err(ManifestError::DuplicateSecret(secret.clone()));
        }
    }
    Ok(())
}