coil-wasm 0.1.0

WASM extension runtime and host APIs for the Coil framework.
Documentation
use std::collections::BTreeMap;

use crate::error::WasmModelError;
use crate::ids::{ExtensionPointKind, HandlerId};
use crate::invocation::InvocationInput;
use crate::points::ExtensionPoint;

pub(crate) fn require_non_empty(
    field: &'static str,
    value: String,
) -> Result<String, WasmModelError> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        Err(WasmModelError::EmptyField { field })
    } else {
        Ok(trimmed.to_string())
    }
}

pub(crate) fn validate_token(field: &'static str, value: String) -> Result<String, WasmModelError> {
    let trimmed = require_non_empty(field, value)?;
    if trimmed
        .chars()
        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/'))
    {
        Ok(trimmed)
    } else {
        Err(WasmModelError::InvalidToken {
            field,
            value: trimmed,
        })
    }
}

pub(crate) fn validate_sha256(
    field: &'static str,
    value: String,
) -> Result<String, WasmModelError> {
    let trimmed = require_non_empty(field, value)?;
    if trimmed.len() == 64
        && trimmed
            .chars()
            .all(|ch| ch.is_ascii_hexdigit() && !ch.is_ascii_uppercase())
    {
        Ok(trimmed)
    } else {
        Err(WasmModelError::InvalidChecksum {
            field,
            value: trimmed,
        })
    }
}

pub(crate) fn validate_route(field: &'static str, route: String) -> Result<String, WasmModelError> {
    let route = require_non_empty(field, route)?;
    if route.starts_with('/') {
        Ok(route)
    } else {
        Err(WasmModelError::InvalidRoute { field, route })
    }
}

pub(crate) fn validate_invocation_target(
    handler_id: &HandlerId,
    point: &ExtensionPoint,
    input: &InvocationInput,
) -> Result<(), WasmModelError> {
    match (point, input) {
        (ExtensionPoint::Page(page), InvocationInput::Page(invocation)) => {
            if page.route != invocation.route {
                return Err(WasmModelError::InvocationTargetMismatch {
                    handler_id: handler_id.to_string(),
                    detail: format!(
                        "page route `{}` does not match registered route `{}`",
                        invocation.route, page.route
                    ),
                });
            }
            if !page.methods.contains(&invocation.method) {
                return Err(WasmModelError::InvocationTargetMismatch {
                    handler_id: handler_id.to_string(),
                    detail: format!(
                        "page method `{}` is not enabled for `{}`",
                        invocation.method, page.route
                    ),
                });
            }
        }
        (ExtensionPoint::Api(api), InvocationInput::Api(invocation)) => {
            if api.route != invocation.route {
                return Err(WasmModelError::InvocationTargetMismatch {
                    handler_id: handler_id.to_string(),
                    detail: format!(
                        "api route `{}` does not match registered route `{}`",
                        invocation.route, api.route
                    ),
                });
            }
            if !api.methods.contains(&invocation.method) {
                return Err(WasmModelError::InvocationTargetMismatch {
                    handler_id: handler_id.to_string(),
                    detail: format!(
                        "api method `{}` is not enabled for `{}`",
                        invocation.method, api.route
                    ),
                });
            }
        }
        (ExtensionPoint::Job(job), InvocationInput::Job(invocation)) => {
            if job.job_name != invocation.job_name {
                return Err(WasmModelError::InvocationTargetMismatch {
                    handler_id: handler_id.to_string(),
                    detail: format!(
                        "job `{}` does not match registered job `{}`",
                        invocation.job_name, job.job_name
                    ),
                });
            }
        }
        (ExtensionPoint::ScheduledJob(job), InvocationInput::ScheduledJob(invocation)) => {
            if job.job_name != invocation.job_name {
                return Err(WasmModelError::InvocationTargetMismatch {
                    handler_id: handler_id.to_string(),
                    detail: format!(
                        "scheduled job `{}` does not match registered job `{}`",
                        invocation.job_name, job.job_name
                    ),
                });
            }
        }
        (ExtensionPoint::Webhook(webhook), InvocationInput::Webhook(invocation)) => {
            if !invocation.verified {
                return Err(WasmModelError::UnverifiedWebhook {
                    handler_id: handler_id.to_string(),
                });
            }
            if !invocation.replay_protected {
                return Err(WasmModelError::ReplayUnsafeWebhook {
                    handler_id: handler_id.to_string(),
                });
            }
            if webhook.source != invocation.source || webhook.event != invocation.event {
                return Err(WasmModelError::InvocationTargetMismatch {
                    handler_id: handler_id.to_string(),
                    detail: format!(
                        "webhook `{}/{}` does not match registered `{}/{}`",
                        invocation.source, invocation.event, webhook.source, webhook.event
                    ),
                });
            }
        }
        (ExtensionPoint::AdminWidget(widget), InvocationInput::AdminWidget(invocation)) => {
            if widget.slot != invocation.slot {
                return Err(WasmModelError::InvocationTargetMismatch {
                    handler_id: handler_id.to_string(),
                    detail: format!(
                        "admin slot `{}` does not match registered slot `{}`",
                        invocation.slot, widget.slot
                    ),
                });
            }
        }
        (ExtensionPoint::RenderHook(hook), InvocationInput::RenderHook(invocation)) => {
            if hook.slot != invocation.slot {
                return Err(WasmModelError::InvocationTargetMismatch {
                    handler_id: handler_id.to_string(),
                    detail: format!(
                        "render slot `{}` does not match registered slot `{}`",
                        invocation.slot, hook.slot
                    ),
                });
            }
        }
        _ => {
            return Err(WasmModelError::InvocationPointMismatch {
                handler_id: handler_id.to_string(),
                expected: point.kind(),
                actual: input.kind(),
            });
        }
    }

    Ok(())
}

pub(crate) fn register_unique_target<K>(
    registry: &mut BTreeMap<K, crate::registry::RegisteredExtensionHandler>,
    key: K,
    binding: crate::registry::RegisteredExtensionHandler,
    target: String,
    point: ExtensionPointKind,
) -> Result<(), WasmModelError>
where
    K: Ord,
{
    if let Some(existing) = registry.insert(key, binding.clone()) {
        return Err(WasmModelError::DuplicateExtensionTarget {
            point,
            target,
            existing_handler: format!("{}::{}", existing.extension_id, existing.handler_id),
            conflicting_handler: format!("{}::{}", binding.extension_id, binding.handler_id),
        });
    }

    Ok(())
}