coil-wasm 0.1.1

WASM extension runtime and host APIs for the Coil framework.
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use sha2::{Digest, Sha256};

use crate::error::WasmModelError;
use crate::ids::ExtensionId;
use crate::manifest::ExtensionArtifactSource;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstalledArtifact {
    pub publisher: String,
    pub source: ExtensionArtifactSource,
    pub sha256: String,
}

impl InstalledArtifact {
    pub fn new(
        publisher: impl Into<String>,
        source: ExtensionArtifactSource,
        sha256: impl Into<String>,
    ) -> Result<Self, WasmModelError> {
        Ok(Self {
            publisher: crate::validation::require_non_empty(
                "extension_publisher",
                publisher.into(),
            )?,
            source,
            sha256: crate::validation::validate_sha256("extension_artifact_sha256", sha256.into())?,
        })
    }

    pub fn resolve_path(&self, base_dir: impl AsRef<Path>, extension_id: &ExtensionId) -> PathBuf {
        self.source.resolve_path(base_dir, extension_id)
    }

    pub fn compiled_module_cache_key(&self) -> &str {
        &self.sha256
    }

    pub fn load_bytes(
        &self,
        base_dir: impl AsRef<Path>,
        extension_id: &ExtensionId,
    ) -> Result<Vec<u8>, WasmModelError> {
        let path = self.resolve_path(base_dir, extension_id);
        let bytes = fs::read(&path).map_err(|error| WasmModelError::ArtifactRead {
            path: path.display().to_string(),
            reason: error.to_string(),
        })?;
        verify_checksum(&self.sha256, &path, &bytes)?;
        Ok(bytes)
    }
}

impl ExtensionArtifactSource {
    pub fn resolve_path(&self, base_dir: impl AsRef<Path>, _extension_id: &ExtensionId) -> PathBuf {
        let base_dir = base_dir.as_ref();
        match self {
            Self::LocalPath(path) => {
                let path = PathBuf::from(path);
                if path.is_absolute() {
                    path
                } else {
                    base_dir.join(path)
                }
            }
            Self::RegistryPackage { registry, package } => base_dir
                .join("registry")
                .join(registry)
                .join(format!("{package}.wasm")),
            Self::FirstPartyCatalog { package } => {
                base_dir.join("catalog").join(format!("{package}.wasm"))
            }
        }
    }
}

fn verify_checksum(expected: &str, path: &Path, bytes: &[u8]) -> Result<(), WasmModelError> {
    let actual = hex::encode(Sha256::digest(bytes));
    if actual == expected {
        Ok(())
    } else {
        Err(WasmModelError::ArtifactChecksumMismatch {
            path: path.display().to_string(),
            expected: expected.to_string(),
            actual,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn compiled_module_cache_key_tracks_the_artifact_checksum() {
        let artifact = InstalledArtifact::new(
            "publisher",
            ExtensionArtifactSource::FirstPartyCatalog {
                package: "example".to_string(),
            },
            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
        )
        .expect("artifact is valid");

        assert_eq!(
            artifact.compiled_module_cache_key(),
            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
        );
    }
}