katana-render-runtime 0.3.1

Versioned render runtime for KatanA diagrams and math (Mermaid, Draw.io, ZenUML, PlantUML, MathJax).
Documentation
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};

pub const PLANTUML_JAR_VERSION: &str = "1.2026.4";
pub const PLANTUML_JAR_CHECKSUM: &str =
    "1783d4569855f2f0a17e65bd192add377c7f2b5e3e1781b65dc94d084de98699";
pub const PLANTUML_DOWNLOAD_URL: &str = "https://repo1.maven.org/maven2/net/sourceforge/plantuml/plantuml-lgpl/1.2026.4/plantuml-lgpl-1.2026.4.jar";

const DOWNLOAD_LIMIT_BYTES: u64 = 32 * 1024 * 1024;
const HEX_HIGH_NIBBLE_SHIFT: u8 = 4;
const HEX_LOW_NIBBLE_MASK: u8 = 0x0f;
const KRR_PLANTUML_CACHE_ENV: &str = "KRR_PLANTUML_CACHE_DIR";
const KDR_PLANTUML_CACHE_ENV: &str = "KDR_PLANTUML_CACHE_DIR";

pub(crate) struct PlantUmlJarAssetOps;

impl PlantUmlJarAssetOps {
    pub(crate) fn cache_path(cache_dir: Option<&Path>) -> PathBuf {
        Self::cache_root(cache_dir)
            .join(PLANTUML_JAR_VERSION)
            .join("plantuml.jar")
    }

    pub(crate) fn prepare_cache_jar(cache_dir: Option<&Path>) -> Result<PathBuf, String> {
        let path = Self::cache_path(cache_dir);
        if path.exists() {
            Self::verify_jar(&path)?;
            return Ok(path);
        }
        Self::download_to_cache(&path)?;
        Ok(path)
    }

    pub(crate) fn verify_jar(path: &Path) -> Result<(), String> {
        let digest = Self::digest_file(path)?;
        Self::verify_digest(&digest)
    }

    fn download_to_cache(path: &Path) -> Result<(), String> {
        let parent = path.parent().ok_or_else(|| {
            format!(
                "PlantUML cache path has no parent directory: {}",
                path.display()
            )
        })?;
        std::fs::create_dir_all(parent).map_err(|error| {
            format!(
                "PlantUML cache directory is not writable: {}: {error}",
                parent.display()
            )
        })?;
        let bytes = Self::download_jar()?;
        Self::verify_bytes(&bytes)?;
        let temp_path = Self::temp_path(path);
        std::fs::write(&temp_path, bytes).map_err(|error| {
            format!(
                "PlantUML cache file is not writable: {}: {error}",
                temp_path.display()
            )
        })?;
        Self::install_temp_file(&temp_path, path)
    }

    fn download_jar() -> Result<Vec<u8>, String> {
        let mut response = ureq::get(PLANTUML_DOWNLOAD_URL)
            .call()
            .map_err(Self::download_error)?;
        response
            .body_mut()
            .with_config()
            .limit(DOWNLOAD_LIMIT_BYTES)
            .read_to_vec()
            .map_err(Self::download_error)
    }

    fn download_error(error: ureq::Error) -> String {
        format!(
            "PlantUML JAR download failed from {PLANTUML_DOWNLOAD_URL}: {error}. network connection is required on first use when the cache is empty"
        )
    }

    fn install_temp_file(temp_path: &Path, path: &Path) -> Result<(), String> {
        match std::fs::rename(temp_path, path) {
            Ok(()) => Ok(()),
            Err(_) if path.exists() => {
                let _ = std::fs::remove_file(temp_path);
                Self::verify_jar(path).map_err(|checksum_error| {
                    format!(
                        "PlantUML cache install raced and existing file is invalid: {checksum_error}"
                    )
                })
            }
            Err(error) => Err(format!(
                "PlantUML cache file could not be installed: {} -> {}: {error}",
                temp_path.display(),
                path.display()
            )),
        }
    }

    fn verify_bytes(bytes: &[u8]) -> Result<(), String> {
        Self::verify_digest(&Self::digest_bytes(bytes))
    }

    fn verify_digest(digest: &str) -> Result<(), String> {
        if digest == PLANTUML_JAR_CHECKSUM {
            return Ok(());
        }
        Err(format!(
            "plantuml.jar checksum mismatch: expected {PLANTUML_JAR_CHECKSUM}, actual {digest}"
        ))
    }

    fn digest_file(path: &Path) -> Result<String, String> {
        let bytes = std::fs::read(path).map_err(|error| error.to_string())?;
        Ok(Self::digest_bytes(&bytes))
    }

    fn digest_bytes(bytes: &[u8]) -> String {
        let digest = Sha256::digest(bytes);
        Self::hex_lower(&digest)
    }

    fn hex_lower(bytes: &[u8]) -> String {
        const HEX: &[u8; 16] = b"0123456789abcdef";
        let mut output = String::with_capacity(bytes.len() * 2);
        for byte in bytes {
            let value = *byte;
            output.push(HEX[(value >> HEX_HIGH_NIBBLE_SHIFT) as usize] as char);
            output.push(HEX[(value & HEX_LOW_NIBBLE_MASK) as usize] as char);
        }
        output
    }

    fn temp_path(path: &Path) -> PathBuf {
        let file_name = path
            .file_name()
            .and_then(|it| it.to_str())
            .unwrap_or("plantuml.jar");
        path.with_file_name(format!("{file_name}.tmp-{}", std::process::id()))
    }

    fn cache_root(cache_dir: Option<&Path>) -> PathBuf {
        if let Some(path) = cache_dir {
            return path.to_path_buf();
        }
        Self::env_path(KRR_PLANTUML_CACHE_ENV)
            .or_else(|| Self::env_path(KDR_PLANTUML_CACHE_ENV))
            .unwrap_or_else(Self::platform_cache_root)
    }

    #[cfg(target_os = "macos")]
    fn platform_cache_root() -> PathBuf {
        Self::home_dir()
            .map(|it| {
                it.join("Library")
                    .join("Caches")
                    .join("krr")
                    .join("plantuml")
            })
            .unwrap_or_else(Self::temp_cache_root)
    }

    #[cfg(target_os = "windows")]
    fn platform_cache_root() -> PathBuf {
        std::env::var_os("LOCALAPPDATA")
            .map(PathBuf::from)
            .or_else(|| Self::home_dir().map(|it| it.join("AppData").join("Local")))
            .map(|it| it.join("krr").join("plantuml"))
            .unwrap_or_else(Self::temp_cache_root)
    }

    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
    fn platform_cache_root() -> PathBuf {
        std::env::var_os("XDG_CACHE_HOME")
            .map(PathBuf::from)
            .or_else(|| Self::home_dir().map(|it| it.join(".cache")))
            .map(|it| it.join("krr").join("plantuml"))
            .unwrap_or_else(Self::temp_cache_root)
    }

    fn temp_cache_root() -> PathBuf {
        std::env::temp_dir().join("krr").join("plantuml")
    }

    fn env_path(name: &'static str) -> Option<PathBuf> {
        let value = std::env::var_os(name)?;
        (!value.is_empty()).then(|| PathBuf::from(value))
    }

    fn home_dir() -> Option<PathBuf> {
        std::env::var_os("HOME")
            .or_else(|| std::env::var_os("USERPROFILE"))
            .map(PathBuf::from)
    }
}

#[cfg(test)]
#[path = "asset_tests.rs"]
mod tests;