greentic-component-store 0.4.76

Store abstraction and verification for Greentic components
Documentation
use std::fs as std_fs;
use std::path::{Path, PathBuf};

use percent_encoding::percent_decode_str;
use sha2::{Digest as _, Sha256};
use thiserror::Error;
use tracing::debug;
use url::Url;

pub mod fs;
#[cfg(feature = "http")]
pub mod http;
pub mod oci;
pub mod verify;
pub mod warg;

pub use verify::{
    DigestAlgorithm, DigestPolicy, SignaturePolicy, VerificationError, VerificationPolicy,
    VerificationReport, VerifiedDigest, VerifiedSignature,
};

#[derive(Debug, Clone)]
pub struct ComponentStore {
    cache_root: PathBuf,
    #[cfg(feature = "http")]
    http_client: reqwest::blocking::Client,
}

impl ComponentStore {
    pub fn new(cache_root: impl AsRef<Path>) -> Result<Self, StoreError> {
        let cache_root = cache_root.as_ref().to_path_buf();
        std_fs::create_dir_all(&cache_root)?;
        Ok(Self {
            cache_root,
            #[cfg(feature = "http")]
            http_client: http::build_client()?,
        })
    }

    pub fn with_default_cache() -> Result<Self, StoreError> {
        let default = default_cache_dir();
        Self::new(default)
    }

    pub fn cache_root(&self) -> &Path {
        &self.cache_root
    }

    pub fn fetch_from_str(
        &self,
        locator: &str,
        policy: &VerificationPolicy,
    ) -> Result<StoreArtifact, StoreError> {
        let locator = StoreLocator::parse(locator)?;
        self.fetch(&locator, policy)
    }

    pub fn fetch(
        &self,
        locator: &StoreLocator,
        policy: &VerificationPolicy,
    ) -> Result<StoreArtifact, StoreError> {
        if let Some(expected) = policy.digest.as_ref().and_then(|d| d.expected()) {
            let cache_path = self.cache_root.join(format!("{expected}.wasm"));
            if cache_path.exists() {
                debug!("cache hit for digest {expected}");
                let bytes = std_fs::read(&cache_path)?;
                let report = policy.verify(&bytes)?;
                return Ok(StoreArtifact {
                    locator: locator.clone(),
                    path: cache_path,
                    bytes,
                    verification: report,
                });
            }
        }

        if let Some(artifact) = self.try_fetch_cached(locator, policy)? {
            return Ok(artifact);
        }

        let bytes = self.fetch_bytes(locator)?;
        let report = policy.verify(&bytes)?;
        let digest = report
            .digest
            .clone()
            .unwrap_or_else(|| VerifiedDigest::compute(DigestAlgorithm::Sha256, &bytes));
        let cache_path = self.persist(locator, &bytes, &digest)?;
        Ok(StoreArtifact {
            locator: locator.clone(),
            path: cache_path,
            bytes,
            verification: VerificationReport {
                digest: Some(digest),
                signature: report.signature,
            },
        })
    }

    fn try_fetch_cached(
        &self,
        locator: &StoreLocator,
        policy: &VerificationPolicy,
    ) -> Result<Option<StoreArtifact>, StoreError> {
        let cache_key = self.compute_cache_key(locator);
        let cache_path = self.cache_root.join(format!("{cache_key}.wasm"));
        if !cache_path.exists() {
            return Ok(None);
        }

        let bytes = std_fs::read(&cache_path)?;
        let report = policy.verify(&bytes)?;
        let digest = report
            .digest
            .clone()
            .unwrap_or_else(|| VerifiedDigest::compute(DigestAlgorithm::Sha256, &bytes));
        let digest_path = self.persist(locator, &bytes, &digest)?;
        Ok(Some(StoreArtifact {
            locator: locator.clone(),
            path: digest_path,
            bytes,
            verification: VerificationReport {
                digest: Some(digest),
                signature: report.signature,
            },
        }))
    }

    fn fetch_bytes(&self, locator: &StoreLocator) -> Result<Vec<u8>, StoreError> {
        match locator {
            StoreLocator::Fs { path, .. } => crate::fs::fetch(path),
            StoreLocator::Http(url) => {
                #[cfg(feature = "http")]
                {
                    http::fetch(&self.http_client, url)
                }
                #[cfg(not(feature = "http"))]
                {
                    let _ = url;
                    Err(StoreError::UnsupportedScheme("http".into()))
                }
            }
            StoreLocator::Https(url) => {
                #[cfg(feature = "http")]
                {
                    http::fetch(&self.http_client, url)
                }
                #[cfg(not(feature = "http"))]
                {
                    let _ = url;
                    Err(StoreError::UnsupportedScheme("https".into()))
                }
            }
            StoreLocator::Oci(reference) => oci::fetch(reference),
            StoreLocator::Warg(reference) => warg::fetch(reference),
        }
    }

    fn persist(
        &self,
        locator: &StoreLocator,
        bytes: &[u8],
        digest: &VerifiedDigest,
    ) -> Result<PathBuf, StoreError> {
        let mut file_name = digest.value.clone();
        file_name.push_str(".wasm");
        let path = self.cache_root.join(&file_name);
        std_fs::write(&path, bytes)?;

        let locator_cache = self
            .cache_root
            .join(format!("{}.wasm", self.compute_cache_key(locator)));
        if locator_cache != path
            && let Err(err) = std_fs::write(&locator_cache, bytes)
        {
            debug!(
                "failed to update locator cache at {}: {}",
                locator_cache.display(),
                err
            );
        }

        debug!("cached artifact {:?} at {}", locator, path.display());
        Ok(path)
    }
}

fn default_cache_dir() -> PathBuf {
    std::env::temp_dir().join("greentic-component-cache")
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StoreLocator {
    Fs { path: PathBuf, locator: String },
    Http(Url),
    Https(Url),
    Oci(String),
    Warg(String),
}

impl StoreLocator {
    pub fn parse(raw: &str) -> Result<Self, StoreError> {
        if raw.contains("://") {
            let url = Url::parse(raw).map_err(|err| StoreError::InvalidLocator {
                locator: raw.to_string(),
                reason: err.to_string(),
            })?;
            match url.scheme() {
                "fs" => {
                    let path = decode_fs_path(&url)?;
                    Ok(StoreLocator::Fs {
                        path,
                        locator: raw.to_string(),
                    })
                }
                "file" => {
                    let path = url.to_file_path().map_err(|_| StoreError::InvalidLocator {
                        locator: raw.to_string(),
                        reason: "unable to convert file URL to path".into(),
                    })?;
                    Ok(StoreLocator::Fs {
                        path,
                        locator: raw.to_string(),
                    })
                }
                "http" => Ok(StoreLocator::Http(url)),
                "https" => Ok(StoreLocator::Https(url)),
                "oci" => Ok(StoreLocator::Oci(url.to_string())),
                "warg" => Ok(StoreLocator::Warg(url.to_string())),
                other => Err(StoreError::UnsupportedScheme(other.to_string())),
            }
        } else {
            let path = PathBuf::from(raw);
            let path = canonicalize_or(path);
            Ok(StoreLocator::Fs {
                path,
                locator: raw.to_string(),
            })
        }
    }

    pub fn as_cache_key(&self) -> String {
        match self {
            StoreLocator::Fs { locator, .. } => locator.clone(),
            StoreLocator::Http(url) | StoreLocator::Https(url) => url.as_str().to_string(),
            StoreLocator::Oci(reference) | StoreLocator::Warg(reference) => reference.clone(),
        }
    }
}

#[derive(Debug)]
pub struct StoreArtifact {
    pub locator: StoreLocator,
    pub path: PathBuf,
    pub bytes: Vec<u8>,
    pub verification: VerificationReport,
}

#[derive(Debug, Error)]
pub enum StoreError {
    #[error("invalid locator `{locator}`: {reason}")]
    InvalidLocator { locator: String, reason: String },
    #[error("unsupported locator scheme `{0}`")]
    UnsupportedScheme(String),
    #[error(transparent)]
    Io(#[from] std::io::Error),
    #[cfg(feature = "http")]
    #[error(transparent)]
    Http(#[from] reqwest::Error),
    #[error(transparent)]
    Verification(#[from] VerificationError),
}

fn decode_fs_path(url: &Url) -> Result<PathBuf, StoreError> {
    let mut path = String::new();
    if let Some(host) = url.host_str().filter(|host| !host.is_empty()) {
        path.push_str("//");
        path.push_str(host);
    }
    path.push_str(url.path());
    let decoded = percent_decode_str(&path)
        .decode_utf8()
        .map_err(|err: std::str::Utf8Error| StoreError::InvalidLocator {
            locator: url.to_string(),
            reason: err.to_string(),
        })?;
    let buf = PathBuf::from(decoded.as_ref());
    Ok(canonicalize_or(buf))
}

fn canonicalize_or(path: PathBuf) -> PathBuf {
    std_fs::canonicalize(&path).unwrap_or(path)
}

fn hash_locator(locator: &StoreLocator) -> String {
    let mut hasher = Sha256::new();
    hasher.update(locator.as_cache_key().as_bytes());
    hex::encode(hasher.finalize())
}

impl ComponentStore {
    fn compute_cache_key(&self, locator: &StoreLocator) -> String {
        hash_locator(locator)
    }
}