use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use tree_sitter::Language;
use tree_sitter_language::LanguageFn;
use crate::core::addons::grammar_manifest::GRAMMAR_SYMBOL;
use crate::core::addons::{binhash, grammar_install, grammar_registry};
fn current_target_triple() -> &'static str {
if cfg!(all(target_arch = "x86_64", target_os = "windows")) {
"x86_64-pc-windows-msvc"
} else if cfg!(all(target_arch = "aarch64", target_os = "windows")) {
"aarch64-pc-windows-msvc"
} else if cfg!(all(target_arch = "x86_64", target_os = "macos")) {
"x86_64-apple-darwin"
} else if cfg!(all(target_arch = "aarch64", target_os = "macos")) {
"aarch64-apple-darwin"
} else if cfg!(all(target_arch = "x86_64", target_os = "linux")) {
"x86_64-unknown-linux-gnu"
} else if cfg!(all(target_arch = "aarch64", target_os = "linux")) {
"aarch64-unknown-linux-gnu"
} else {
"unknown"
}
}
fn dylib_dir(name: &str) -> Option<PathBuf> {
Some(
crate::core::data_dir::lean_ctx_data_dir()
.ok()?
.join("grammars")
.join(name),
)
}
#[cfg(unix)]
fn is_world_writable(path: &std::path::Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path).is_ok_and(|m| m.permissions().mode() & 0o022 != 0)
}
#[cfg(not(unix))]
fn is_world_writable(_path: &std::path::Path) -> bool {
false
}
fn load_uncached(ext: &str) -> Option<Language> {
let manifest = grammar_registry::find_by_extension(ext)?;
let asset = manifest.asset_for(current_target_triple())?;
let path = dylib_dir(&manifest.name)?.join(&asset.filename);
if !path.is_file()
&& let Err(e) = grammar_install::ensure_installed(&manifest, asset, &path)
{
tracing::debug!("grammar addon `{}` not available: {e}", manifest.name);
return None;
}
if let Some(dir) = path.parent()
&& (is_world_writable(dir) || is_world_writable(&path))
{
tracing::warn!(
"[SECURITY] grammar addon `{}` dir or dylib is world-writable — refusing to load",
manifest.name
);
return None;
}
let actual_hash = binhash::sha256_file(&path).ok()?;
if !actual_hash.eq_ignore_ascii_case(&asset.sha256) {
tracing::warn!(
"[SECURITY] grammar addon `{}` dylib hash mismatch — refusing to load",
manifest.name
);
return None;
}
let language = unsafe {
let lib = libloading::Library::new(&path).ok()?;
let sym: libloading::Symbol<unsafe extern "C" fn() -> *const ()> =
lib.get(GRAMMAR_SYMBOL).ok()?;
let language: Language = LanguageFn::from_raw(*sym).into();
std::mem::forget(lib);
language
};
if language.abi_version() != manifest.abi_version as usize {
tracing::warn!(
"[SECURITY] grammar addon `{}` abi_version {} != manifest {} — refusing to load",
manifest.name,
language.abi_version(),
manifest.abi_version
);
return None;
}
Some(language)
}
pub(super) fn get_addon_language(ext: &str) -> Option<Language> {
static CACHE: OnceLock<Mutex<HashMap<String, Option<Language>>>> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(hit) = cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(ext)
{
return hit.clone();
}
let result = load_uncached(ext);
cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.entry(ext.to_string())
.or_insert(result)
.clone()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn current_target_triple_is_known_on_ci_platforms() {
if cfg!(any(
target_os = "windows",
target_os = "macos",
target_os = "linux"
)) && cfg!(any(target_arch = "x86_64", target_arch = "aarch64"))
{
assert_ne!(current_target_triple(), "unknown");
}
}
#[test]
fn missing_extension_returns_none_without_panicking() {
assert!(get_addon_language("this-extension-has-no-addon-xyz").is_none());
}
#[cfg(unix)]
#[test]
fn world_writable_dir_is_detected() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!(
"lc-grammar-loader-test-{}-world-writable",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o777)).unwrap();
assert!(is_world_writable(&dir));
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(!is_world_writable(&dir));
std::fs::remove_dir_all(&dir).ok();
}
}