warpgate_api 0.18.0

APIs for working with Warpgate plugins.
Documentation
use crate::locator_error::PluginLocatorError;
use serde::{Deserialize, Serialize};
use std::fmt::{self, Debug, Display};
use std::path::PathBuf;
use std::str::FromStr;

/// An inline data locator.
#[derive(Clone, Default, Eq, PartialEq)]
pub struct DataLocator {
    /// Base64 encoded data (with data://).
    pub data: String,

    /// The decoded bytes of the data.
    /// This must be done manually on the host side.
    pub bytes: Option<Vec<u8>>,
}

impl Display for DataLocator {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if self.data.starts_with("data://") {
            write!(f, "{}", self.data)
        } else {
            write!(f, "data://{}", self.data)
        }
    }
}

impl Debug for DataLocator {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        // 32 hash characters + 7 prefix characters (data://)
        let mut data = self.data.chars().take(39).collect::<String>();

        if self.data.len() > 39 {
            data.push_str("...");
        }

        f.debug_struct("DataLocator").field("data", &data).finish()
    }
}

/// A file system locator.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct FileLocator {
    /// Path explicitly configured by a user (with file://).
    pub file: String,

    /// The file (above) resolved to an absolute path.
    /// This must be done manually on the host side.
    pub path: Option<PathBuf>,
}

#[cfg(not(target_arch = "wasm32"))]
impl FileLocator {
    /// Return the configured file path as-is, without the `file://` prefix,
    /// and without resolving it to an absolute path.
    pub fn get_unresolved_path(&self) -> PathBuf {
        PathBuf::from(self.file.strip_prefix("file://").unwrap_or(&self.file))
    }

    /// Return an absolute file path. If a path has not been resolved on the
    /// host side, the unresolved path will be joined to the current working directory.
    pub fn get_resolved_path(&self) -> PathBuf {
        let mut path = self
            .path
            .clone()
            .unwrap_or_else(|| self.get_unresolved_path());

        if !path.is_absolute() {
            path = std::env::current_dir()
                .expect("Could not determine working directory!")
                .join(path);
        }

        path
    }
}

impl Display for FileLocator {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if self.file.starts_with("file://") {
            write!(f, "{}", self.file)
        } else {
            write!(f, "file://{}", self.file)
        }
    }
}

/// A GitHub release locator.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct GitHubLocator {
    /// Owner/org and repository name: `owner/repo`.
    pub repo_slug: String,

    /// Explicit release tag to use. Defaults to `latest`.
    pub tag: Option<String>,

    /// Project name to match tags against. Primarily used in monorepos.
    pub project_name: Option<String>,
}

impl Display for GitHubLocator {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "github://{}{}{}",
            self.repo_slug,
            self.project_name
                .as_deref()
                .map(|n| format!("/{n}"))
                .unwrap_or_default(),
            self.tag
                .as_deref()
                .map(|t| format!("@{t}"))
                .unwrap_or_default()
        )
    }
}

/// A HTTPS URL locator.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct UrlLocator {
    /// URL explicitly configured by a user (with https://).
    pub url: String,
}

impl Display for UrlLocator {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.url)
    }
}

/// An OCI registry locator.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct RegistryLocator {
    /// Registry host: `ghcr.io`.
    pub registry: Option<String>,

    /// Namespace or organization: `org/namespace`.
    pub namespace: Option<String>,

    /// The image name (plugin identifier): `plugin`
    pub image: String,

    /// Explicit release tag to use. Defaults to `latest`.
    pub tag: Option<String>,
}

impl Display for RegistryLocator {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "registry://{}:{}",
            vec![
                self.registry.clone(),
                self.namespace.clone(),
                Some(self.image.clone())
            ]
            .into_iter()
            .flatten()
            .collect::<Vec<_>>()
            .join("/"),
            self.tag.as_deref().unwrap_or("latest")
        )
    }
}

/// Strategies and protocols for locating plugins.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(untagged, into = "String", try_from = "String")]
pub enum PluginLocator {
    /// data://base64encodeddata
    Data(Box<DataLocator>),

    /// file:///abs/path/to/file.wasm
    /// file://../rel/path/to/file.wasm
    File(Box<FileLocator>),

    /// github://owner/repo
    /// github://owner/repo@tag
    /// github://owner/repo/project
    GitHub(Box<GitHubLocator>),

    /// https://url/to/file.wasm
    Url(Box<UrlLocator>),

    /// registry://plugins/python
    /// registry://plugins/python:tag
    Registry(Box<RegistryLocator>),
}

#[cfg(feature = "schematic")]
impl schematic::Schematic for PluginLocator {
    fn schema_name() -> Option<String> {
        Some("PluginLocator".into())
    }

    fn build_schema(mut schema: schematic::SchemaBuilder) -> schematic::Schema {
        schema.set_description("Strategies and protocols for locating plugins.");
        schema.string_default()
    }
}

impl Display for PluginLocator {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            PluginLocator::Data(data) => write!(f, "{data}"),
            PluginLocator::File(file) => write!(f, "{file}"),
            PluginLocator::Url(url) => write!(f, "{url}"),
            PluginLocator::GitHub(github) => write!(f, "{github}"),
            PluginLocator::Registry(registry) => write!(f, "{registry}"),
        }
    }
}

impl FromStr for PluginLocator {
    type Err = PluginLocatorError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        PluginLocator::try_from(value.to_owned())
    }
}

impl TryFrom<String> for PluginLocator {
    type Error = PluginLocatorError;

    fn try_from(value: String) -> Result<Self, Self::Error> {
        // Legacy support
        if let Some(source) = value.strip_prefix("source:") {
            if source.starts_with("http") {
                return Self::try_from(source.to_owned());
            } else {
                return Self::try_from(format!("file://{source}"));
            }
        } else if value.starts_with("github:") && !value.contains("//") {
            return Self::try_from(format!("github://{}", &value[7..]));
        }

        if !value.contains("://") {
            return Err(PluginLocatorError::MissingProtocol);
        }

        let mut parts = value.splitn(2, "://");

        let Some(protocol) = parts.next() else {
            return Err(PluginLocatorError::MissingProtocol);
        };

        let Some(location) = parts.next() else {
            return Err(PluginLocatorError::MissingLocation);
        };

        if location.is_empty() {
            return Err(PluginLocatorError::MissingLocation);
        }

        match protocol {
            "data" => Ok(PluginLocator::Data(Box::new(DataLocator {
                data: value,
                bytes: None,
            }))),
            "file" => Ok(PluginLocator::File(Box::new(FileLocator {
                file: value,
                path: None,
            }))),
            "github" => {
                if !location.contains('/') {
                    return Err(PluginLocatorError::MissingGitHubOrg);
                }

                let mut github = GitHubLocator::default();
                let mut query = location;

                if let Some(index) = query.find('@') {
                    github.tag = Some(query[index + 1..].into());
                    query = &query[0..index];
                }

                let mut parts = query.split('/');
                let org = parts.next().unwrap_or_default().to_owned();
                let repo = parts.next().unwrap_or_default().to_owned();
                let prefix = parts.next().map(|f| f.to_owned());

                github.project_name = prefix;
                github.repo_slug = format!("{org}/{repo}");

                Ok(PluginLocator::GitHub(Box::new(github)))
            }
            "http" => Err(PluginLocatorError::SecureUrlsOnly),
            "https" => Ok(PluginLocator::Url(Box::new(UrlLocator { url: value }))),
            "registry" => {
                let mut registry = RegistryLocator::default();
                let mut query = location;

                if let Some(index) = query.find(":") {
                    registry.tag = Some(query[index + 1..].into());
                    query = &query[0..index];
                }

                if let Some(index) = query.find("/") {
                    let inner = &query[0..index];

                    // Domains contain a period for the TLD
                    if inner.contains('.') {
                        registry.registry = Some(inner.into());
                        query = &query[index + 1..];
                    }
                }

                if let Some(index) = query.rfind('/') {
                    registry.image = query[index + 1..].into();
                    query = &query[0..index];
                } else {
                    registry.image = query.into();
                    query = &query[0..0];
                }

                if !query.is_empty() {
                    registry.namespace = Some(query.into());
                }

                if registry.image.is_empty() {
                    return Err(PluginLocatorError::MissingRegistryImage);
                }

                Ok(PluginLocator::Registry(Box::new(registry)))
            }
            unknown => Err(PluginLocatorError::UnknownProtocol(unknown.to_owned())),
        }
    }
}

impl From<PluginLocator> for String {
    fn from(locator: PluginLocator) -> Self {
        locator.to_string()
    }
}

impl AsRef<PluginLocator> for PluginLocator {
    fn as_ref(&self) -> &Self {
        self
    }
}