llman-core 0.0.77

Foundation utility layer for llman (fs/path/managed-block/env-safety/git/schema plumbing).
Documentation
use anyhow::{Result, bail};

pub fn is_valid_env_key(key: &str) -> bool {
    let mut chars = key.chars();
    let Some(first) = chars.next() else {
        return false;
    };

    if !(first.is_ascii_alphabetic() || first == '_') {
        return false;
    }

    for ch in chars {
        if !(ch.is_ascii_alphanumeric() || ch == '_') {
            return false;
        }
    }

    true
}

pub fn is_dangerous_env_key(key: &str) -> bool {
    let upper = key.to_ascii_uppercase();
    matches!(upper.as_str(), "LD_PRELOAD" | "LD_LIBRARY_PATH" | "PATH")
        || upper.starts_with("DYLD_")
}

pub fn find_dangerous_env_keys<'a>(keys: impl IntoIterator<Item = &'a str>) -> Vec<&'a str> {
    let mut dangerous: Vec<&str> = keys
        .into_iter()
        .filter(|key| is_dangerous_env_key(key))
        .collect();
    dangerous.sort_unstable();
    dangerous.dedup();
    dangerous
}

pub fn reject_dangerous_env_keys<'a, I>(keys: I) -> Result<()>
where
    I: IntoIterator<Item = &'a str>,
{
    let dangerous = find_dangerous_env_keys(keys);
    if dangerous.is_empty() {
        return Ok(());
    }
    bail!(
        "Refused dangerous environment variable key(s): {}",
        dangerous.join(", ")
    )
}

pub fn validate_user_git_ref(reference: &str) -> Result<(), String> {
    let trimmed = reference.trim();
    if trimmed.is_empty() {
        return Err("git ref must not be empty".to_string());
    }
    if trimmed.starts_with('-') {
        return Err(format!(
            "git ref must not start with '-': {trimmed} (refuses option injection)"
        ));
    }
    Ok(())
}

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

    #[test]
    fn dangerous_keys_are_case_insensitive() {
        assert!(is_dangerous_env_key("PATH"));
        assert!(is_dangerous_env_key("path"));
        assert!(is_dangerous_env_key("Ld_Preload"));
        assert!(is_dangerous_env_key("DYLD_LIBRARY_PATH"));
        assert!(is_dangerous_env_key("dyld_insert_libraries"));
        assert!(!is_dangerous_env_key("API_KEY"));
        assert!(!is_dangerous_env_key("MY_PATH"));
    }

    #[test]
    fn reject_dangerous_env_keys_lists_hits() {
        let err = reject_dangerous_env_keys(["FOO", "LD_PRELOAD", "PATH"]).unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("LD_PRELOAD"));
        assert!(msg.contains("PATH"));
        assert!(!msg.contains("FOO"));
    }

    #[test]
    fn validate_user_git_ref_rejects_option_like() {
        assert!(validate_user_git_ref("origin/main").is_ok());
        assert!(validate_user_git_ref("-c").is_err());
        assert!(validate_user_git_ref("--all").is_err());
        assert!(validate_user_git_ref("").is_err());
        assert!(validate_user_git_ref("   ").is_err());
    }
}