runx-runtime 0.6.19

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

pub(super) use crate::time::now_iso8601;

use runx_contracts::operational_policy_source_provider;

use super::super::types::{
    RegistryPublisher, RegistrySkillVersion, RegistrySourceMetadata, TrustTier,
};
use super::LocalRegistryError;

pub(super) fn validate_publisher(
    publisher: RegistryPublisher,
    label: &str,
) -> Result<RegistryPublisher, LocalRegistryError> {
    if !matches!(
        publisher.kind.as_str(),
        "organization" | "user" | "team" | "service" | "publisher"
    ) {
        return Err(LocalRegistryError::InvalidVersionPayload {
            field: format!("{label}.kind"),
            message: "must be one of organization, user, team, service, or publisher".to_owned(),
        });
    }
    if publisher.id.is_empty() {
        return Err(LocalRegistryError::InvalidVersionPayload {
            field: format!("{label}.id"),
            message: "must be a non-empty string".to_owned(),
        });
    }
    Ok(publisher)
}

pub(super) fn validate_source_metadata(
    source_metadata: RegistrySourceMetadata,
) -> Result<RegistrySourceMetadata, LocalRegistryError> {
    if source_metadata.provider != operational_policy_source_provider::GITHUB {
        return Err(LocalRegistryError::InvalidVersionPayload {
            field: "registry_version.source_metadata.provider".to_owned(),
            message: format!("must be {}", operational_policy_source_provider::GITHUB),
        });
    }
    if !matches!(
        source_metadata.event.as_str(),
        "enrollment" | "push" | "tag" | "tombstone"
    ) {
        return Err(LocalRegistryError::InvalidVersionPayload {
            field: "registry_version.source_metadata.event".to_owned(),
            message: "must be one of enrollment, push, tag, or tombstone".to_owned(),
        });
    }
    Ok(source_metadata)
}

pub(super) fn required_string(
    value: Option<String>,
    field: &str,
) -> Result<String, LocalRegistryError> {
    match value {
        Some(value) if !value.is_empty() => Ok(value),
        _ => Err(missing_field(field)),
    }
}

pub(super) fn missing_field(field: &str) -> LocalRegistryError {
    LocalRegistryError::InvalidVersionPayload {
        field: field.to_owned(),
        message: "missing required field".to_owned(),
    }
}

pub(super) fn safe_read_dir_names(path: &Path) -> Result<Vec<String>, LocalRegistryError> {
    match fs::read_dir(path) {
        Ok(entries) => entries
            .map(|entry| {
                let entry = entry.map_err(|source| io_error("reading", path, source))?;
                Ok(entry.file_name().to_string_lossy().into_owned())
            })
            .collect(),
        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
        Err(source) => Err(io_error("reading", path, source)),
    }
}

pub(super) fn write_registry_json(
    path: &Path,
    version: &RegistrySkillVersion,
    create_new: bool,
) -> Result<(), LocalRegistryError> {
    let mut contents =
        serde_json::to_string_pretty(version).map_err(|source| LocalRegistryError::JsonWrite {
            path: path.to_path_buf(),
            source,
        })?;
    contents.push('\n');

    let mut options = fs::OpenOptions::new();
    options.write(true);
    if create_new {
        options.create_new(true);
    } else {
        options.create(true).truncate(true);
    }
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        options.mode(0o600);
    }
    let mut file = options
        .open(path)
        .map_err(|source| io_error("writing", path, source))?;
    file.write_all(contents.as_bytes())
        .map_err(|source| io_error("writing", path, source))
}

pub(super) fn io_error(action: &'static str, path: &Path, source: io::Error) -> LocalRegistryError {
    LocalRegistryError::Io {
        action,
        path: path.to_path_buf(),
        source,
    }
}

pub(super) fn encode_part(value: &str) -> String {
    encode_uri_component(value)
}

pub(super) fn decode_part(value: &str) -> Result<String, LocalRegistryError> {
    let decoded =
        percent_decode(value).map_err(|message| LocalRegistryError::InvalidVersionPayload {
            field: "registry_path".to_owned(),
            message,
        })?;
    if is_unsafe_path_component(&decoded) {
        return Err(LocalRegistryError::UnsafePathComponent(decoded));
    }
    Ok(decoded)
}

pub(super) fn is_unsafe_path_component(value: &str) -> bool {
    matches!(value, "." | "..") || value.contains('/') || value.contains('\\')
}

pub(super) fn reject_unsafe_path_component(value: &str) -> Result<(), LocalRegistryError> {
    if is_unsafe_path_component(value) {
        Err(LocalRegistryError::UnsafePathComponent(value.to_owned()))
    } else {
        Ok(())
    }
}

pub(super) fn encode_uri_component(value: &str) -> String {
    let mut output = String::new();
    for byte in value.bytes() {
        let keep = byte.is_ascii_alphanumeric()
            || matches!(
                byte,
                b'-' | b'_' | b'.' | b'!' | b'~' | b'*' | b'\'' | b'(' | b')'
            );
        if keep {
            output.push(char::from(byte));
        } else {
            output.push_str(&format!("%{byte:02X}"));
        }
    }
    output
}

pub(super) fn percent_decode(value: &str) -> Result<String, String> {
    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'%' {
            if index + 2 >= bytes.len() {
                return Err(format!("invalid percent encoding in '{value}'"));
            }
            let Some(high) = hex_value(bytes[index + 1]) else {
                return Err(format!("invalid percent encoding in '{value}'"));
            };
            let Some(low) = hex_value(bytes[index + 2]) else {
                return Err(format!("invalid percent encoding in '{value}'"));
            };
            decoded.push((high << 4) | low);
            index += 3;
            continue;
        }
        decoded.push(bytes[index]);
        index += 1;
    }
    String::from_utf8(decoded).map_err(|error| error.to_string())
}

pub(super) 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,
    }
}

pub(super) fn display_sha256(digest: &str) -> String {
    if digest.starts_with("sha256:") {
        digest.to_owned()
    } else {
        format!("sha256:{digest}")
    }
}

pub(super) fn trust_tier_string(value: &TrustTier) -> &'static str {
    match value {
        TrustTier::FirstParty => "first_party",
        TrustTier::Verified => "verified",
        TrustTier::Community => "community",
    }
}