runx-runtime 0.6.19

Native Rust runtime for local runx execution, adapters, harness replay, receipts, and sandboxing.
Documentation
use runx_contracts::{JsonObject, JsonValue};
use runx_parser::{SkillRunnerManifest, ValidatedSkill};

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ScopeParseError {
    pub(crate) field: String,
    pub(crate) message: String,
}

impl std::fmt::Display for ScopeParseError {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(formatter, "{} {}", self.field, self.message)
    }
}

impl std::error::Error for ScopeParseError {}

pub(crate) fn required_scopes_from_skill(
    skill: &ValidatedSkill,
) -> Result<Vec<String>, ScopeParseError> {
    Ok(unique_strings(
        string_array_field(skill.auth.as_ref(), "auth.scopes")?
            .into_iter()
            .chain(string_array_field_from_object(
                skill.runx.as_ref(),
                "runx.scopes",
            )?),
    ))
}

pub(super) fn required_scopes_from_skill_and_runner(
    skill: &ValidatedSkill,
    manifest: Option<&SkillRunnerManifest>,
) -> Result<Vec<String>, ScopeParseError> {
    Ok(unique_strings(
        required_scopes_from_skill(skill)?
            .into_iter()
            .chain(required_scopes_from_runner_manifest(manifest)?),
    ))
}

fn required_scopes_from_runner_manifest(
    manifest: Option<&SkillRunnerManifest>,
) -> Result<Vec<String>, ScopeParseError> {
    let mut scopes = Vec::new();
    let Some(manifest) = manifest else {
        return Ok(scopes);
    };
    for (runner_name, runner) in &manifest.runners {
        scopes.extend(string_array_field(
            runner.auth.as_ref(),
            &format!("runners.{runner_name}.auth.scopes"),
        )?);
        scopes.extend(string_array_field_from_object(
            runner.runx.as_ref(),
            &format!("runners.{runner_name}.runx.scopes"),
        )?);
    }
    Ok(unique_strings(scopes))
}

fn string_array_field(
    value: Option<&JsonValue>,
    field: &str,
) -> Result<Vec<String>, ScopeParseError> {
    let Some(value) = value else {
        return Ok(Vec::new());
    };
    let JsonValue::Object(record) = value else {
        return Err(ScopeParseError {
            field: field_parent(field).to_owned(),
            message: "must be an object when declaring scopes".to_owned(),
        });
    };
    string_array_field_from_object(Some(record), field)
}

fn string_array_field_from_object(
    value: Option<&JsonObject>,
    field: &str,
) -> Result<Vec<String>, ScopeParseError> {
    let Some(record) = value else {
        return Ok(Vec::new());
    };
    let scope_key = field.rsplit('.').next().unwrap_or(field);
    let Some(value) = record.get(scope_key) else {
        return Ok(Vec::new());
    };
    let JsonValue::Array(values) = value else {
        return Err(ScopeParseError {
            field: field.to_owned(),
            message: "must be an array of non-empty strings".to_owned(),
        });
    };
    let mut scopes = Vec::new();
    for (index, value) in values.iter().enumerate() {
        let Some(scope) = value
            .as_str()
            .map(str::trim)
            .filter(|scope| !scope.is_empty())
        else {
            return Err(ScopeParseError {
                field: format!("{field}[{index}]"),
                message: "must be a non-empty string".to_owned(),
            });
        };
        scopes.push(scope.to_owned());
    }
    Ok(scopes)
}

fn field_parent(field: &str) -> &str {
    field.rsplit_once('.').map_or(field, |(parent, _)| parent)
}

fn unique_strings(values: impl IntoIterator<Item = String>) -> Vec<String> {
    let mut unique_values = Vec::new();
    for value in values {
        if !unique_values.contains(&value) {
            unique_values.push(value);
        }
    }
    unique_values
}

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

    #[test]
    fn scope_arrays_reject_non_string_entries() -> Result<(), Box<dyn std::error::Error>> {
        let mut record = JsonObject::new();
        record.insert(
            "scopes".to_owned(),
            JsonValue::Array(vec![
                JsonValue::String("repo.read".to_owned()),
                JsonValue::Bool(true),
            ]),
        );

        let error = match string_array_field_from_object(Some(&record), "auth.scopes") {
            Ok(_) => return Err("malformed scope entry should fail closed".into()),
            Err(error) => error,
        };
        assert_eq!(error.field, "auth.scopes[1]");
        Ok(())
    }

    #[test]
    fn scope_arrays_trim_deduplicate_and_reject_empty_entries()
    -> Result<(), Box<dyn std::error::Error>> {
        let mut record = JsonObject::new();
        record.insert(
            "scopes".to_owned(),
            JsonValue::Array(vec![
                JsonValue::String(" repo.read ".to_owned()),
                JsonValue::String("".to_owned()),
            ]),
        );

        let error = match string_array_field_from_object(Some(&record), "auth.scopes") {
            Ok(_) => return Err("empty scope entry should fail closed".into()),
            Err(error) => error,
        };
        assert_eq!(error.field, "auth.scopes[1]");
        Ok(())
    }
}