runx-runtime 0.6.19

Native Rust runtime for local runx execution, adapters, harness replay, receipts, and sandboxing.
Documentation
use std::path::{Path, PathBuf};

use super::http::{RegistryClient, RegistryClientError};
use super::types::ResolvedRegistryRef;

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ParsedRegistryRef {
    pub skill_id: String,
    pub version: Option<String>,
}

#[derive(Debug, thiserror::Error)]
pub enum RegistryResolveError {
    #[error("{0}")]
    Client(#[from] RegistryClientError),
    #[error("Registry ref '{0}' is ambiguous. Use '<owner>/<name>' instead.")]
    Ambiguous(String),
}

pub fn parse_registry_ref(value: &str) -> ParsedRegistryRef {
    let without_protocol = value
        .strip_prefix("runx://skill/")
        .and_then(|encoded| urlencoding_decode(encoded).ok())
        .unwrap_or_else(|| value.to_owned());
    let without_prefix = without_protocol
        .strip_prefix("registry:")
        .or_else(|| without_protocol.strip_prefix("runx-registry:"))
        .unwrap_or(&without_protocol);
    let Some(at_index) = without_prefix.rfind('@') else {
        return ParsedRegistryRef {
            skill_id: without_prefix.to_owned(),
            version: None,
        };
    };
    if at_index == 0 {
        return ParsedRegistryRef {
            skill_id: without_prefix.to_owned(),
            version: None,
        };
    }
    ParsedRegistryRef {
        skill_id: without_prefix[..at_index].to_owned(),
        version: non_empty(&without_prefix[at_index + 1..]),
    }
}

pub fn resolve_remote_registry_ref<T: super::http::Transport>(
    client: &RegistryClient<T>,
    registry_ref: &str,
    version_override: Option<&str>,
) -> Result<Option<ResolvedRegistryRef>, RegistryResolveError> {
    let parsed = parse_registry_ref(registry_ref);
    if parsed.skill_id.contains('/') {
        return Ok(Some(ResolvedRegistryRef {
            skill_id: parsed.skill_id,
            version: version_override.map(ToOwned::to_owned).or(parsed.version),
        }));
    }

    let normalized = parsed.skill_id.trim().to_lowercase();
    let matches = client
        .search_with_limit(&parsed.skill_id, 100)?
        .into_iter()
        .filter(|candidate| candidate.name == normalized)
        .collect::<Vec<_>>();
    match matches.len() {
        0 => Ok(None),
        1 => {
            let candidate = &matches[0];
            Ok(Some(ResolvedRegistryRef {
                skill_id: candidate.skill_id.clone(),
                version: version_override
                    .map(ToOwned::to_owned)
                    .or(parsed.version)
                    .or_else(|| candidate.version.clone()),
            }))
        }
        _ => Err(RegistryResolveError::Ambiguous(parsed.skill_id)),
    }
}

pub fn materialization_cache_path(
    root: &Path,
    owner: &str,
    name: &str,
    version: &str,
    digest: &str,
) -> PathBuf {
    let marker = digest.strip_prefix("sha256:").unwrap_or(digest);
    let short = marker.chars().take(16).collect::<String>();
    root.join(safe_path_part(owner))
        .join(safe_path_part(name))
        .join(safe_path_part(version))
        .join(safe_path_part(&short))
}

pub fn materialization_digest_marker(
    digest: &str,
    profile_digest: Option<&str>,
    package_digest: Option<&str>,
) -> String {
    let profile_digest = profile_digest.unwrap_or("");
    let package_digest = package_digest.unwrap_or("");
    format!("digest={digest}\nprofile_digest={profile_digest}\npackage_digest={package_digest}\n")
}

pub fn safe_skill_package_parts(
    registry_ref: &str,
    skill_name: &str,
    resolved_version: Option<&str>,
) -> Vec<String> {
    let normalized = normalize_install_ref(registry_ref);
    let parsed = parse_registry_ref(registry_ref);
    let version = parsed.version.as_deref().or(resolved_version);
    let raw_parts = if normalized.contains('/') {
        let mut parts = normalized.split('/').collect::<Vec<_>>();
        if let Some(version) = version {
            parts.push(version);
        }
        parts
    } else {
        let mut parts = vec![skill_name];
        if let Some(version) = version {
            parts.push(version);
        }
        parts
    };
    let parts = raw_parts
        .into_iter()
        .map(safe_path_part)
        .filter(|part| !part.is_empty())
        .collect::<Vec<_>>();
    if parts.is_empty() {
        vec![safe_path_part(skill_name)]
    } else {
        parts
    }
}

fn normalize_install_ref(registry_ref: &str) -> String {
    let parsed = parse_registry_ref(registry_ref);
    parsed.skill_id
}

fn safe_path_part(value: &str) -> String {
    let mut output = String::new();
    let mut last_dash = false;
    for ch in value.trim().to_lowercase().chars() {
        let keep = ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-');
        if keep {
            output.push(ch);
            last_dash = false;
        } else if !last_dash {
            output.push('-');
            last_dash = true;
        }
    }
    let trimmed = output.trim_matches('-').to_owned();
    if trimmed.is_empty() || trimmed == "." || trimmed == ".." {
        "skill".to_owned()
    } else {
        trimmed
    }
}

fn urlencoding_decode(value: &str) -> Result<String, std::str::Utf8Error> {
    let mut decoded = Vec::with_capacity(value.len());
    let bytes = value.as_bytes();
    let mut index = 0;
    while index < bytes.len() {
        if bytes[index] == b'%' && index + 2 < bytes.len() {
            let high = hex_value(bytes[index + 1]);
            let low = hex_value(bytes[index + 2]);
            if let (Some(high), Some(low)) = (high, low) {
                decoded.push((high << 4) | low);
                index += 3;
                continue;
            }
        }
        decoded.push(bytes[index]);
        index += 1;
    }
    std::str::from_utf8(&decoded).map(ToOwned::to_owned)
}

fn hex_value(byte: u8) -> Option<u8> {
    match byte {
        b'0'..=b'9' => Some(byte - b'0'),
        b'a'..=b'f' => Some(byte - b'a' + 10),
        b'A'..=b'F' => Some(byte - b'A' + 10),
        _ => None,
    }
}

fn non_empty(value: &str) -> Option<String> {
    if value.is_empty() {
        None
    } else {
        Some(value.to_owned())
    }
}