use crate::event::PluginEvent;
use crate::manifest::Capabilities;
use crate::render::{HighlightRequest, HighlightResponse};
use crate::storage::{JsonFileStorage, PluginStorage};
use crate::traits::*;
use anyhow::Result;
use mlua::{HookTriggers, Lua, LuaOptions, LuaSerdeExt, StdLib, Table, Value};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct SoberInvocation {
pub action: String,
pub options: serde_json::Value,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SoberInvocationResult {
pub ok: bool,
#[serde(default)]
pub data: serde_json::Value,
#[serde(default)]
pub error: Option<String>,
}
#[derive(Clone)]
pub struct SoberHost {
runner: Arc<dyn Fn(SoberInvocation) -> SoberInvocationResult + Send + Sync>,
}
impl std::fmt::Debug for SoberHost {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SoberHost").finish_non_exhaustive()
}
}
impl SoberHost {
pub fn new<F>(runner: F) -> Self
where
F: Fn(SoberInvocation) -> SoberInvocationResult + Send + Sync + 'static,
{
Self {
runner: Arc::new(runner),
}
}
pub fn run(&self, invocation: SoberInvocation) -> SoberInvocationResult {
(self.runner)(invocation)
}
}
#[derive(Debug, Clone)]
pub struct LuaPluginOptions {
pub memory_mb: u32,
pub max_instructions: u64,
pub http_timeout: Duration,
pub network_allow: Vec<String>,
pub network: bool,
pub env_access: bool,
pub sober: Option<SoberHost>,
pub repo_root: Option<PathBuf>,
}
impl Default for LuaPluginOptions {
fn default() -> Self {
Self::from_capabilities(&Capabilities::legacy_default())
}
}
impl LuaPluginOptions {
pub fn from_capabilities(caps: &Capabilities) -> Self {
Self {
memory_mb: caps.memory_mb,
max_instructions: caps.max_instructions,
http_timeout: Duration::from_secs(caps.http_timeout_secs),
network_allow: caps.network_allow.clone(),
network: caps.network,
env_access: caps.env,
sober: None,
repo_root: None,
}
}
}
struct HttpCap {
client: reqwest::blocking::Client,
network: bool,
allow: Vec<String>,
}
impl HttpCap {
fn new(opts: &LuaPluginOptions) -> mlua::Result<Self> {
let client = reqwest::blocking::Client::builder()
.timeout(opts.http_timeout)
.user_agent(concat!("progit-plugin-sdk/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| mlua::Error::RuntimeError(format!("http client init: {e}")))?;
Ok(Self {
client,
network: opts.network,
allow: opts.network_allow.clone(),
})
}
fn check(&self, url: &str) -> mlua::Result<()> {
if !self.network {
return Err(mlua::Error::RuntimeError(
"http: capability 'network' not granted in plugin manifest".into(),
));
}
if self.allow.is_empty() {
return Ok(());
}
let host = match reqwest::Url::parse(url).ok().and_then(|u| u.host_str().map(str::to_string)) {
Some(h) => h.to_lowercase(),
None => {
return Err(mlua::Error::RuntimeError(format!("http: invalid URL '{url}'")));
}
};
let ok = self.allow.iter().any(|a| {
let al = a.to_lowercase();
host == al || host.ends_with(&format!(".{al}"))
});
if ok {
Ok(())
} else {
Err(mlua::Error::RuntimeError(format!(
"http: host '{host}' not in network_allow list"
)))
}
}
}
pub struct LuaPlugin {
lua: Lua,
metadata: PluginMetadata,
context: Option<PluginContext>,
options: LuaPluginOptions,
storage: Arc<Mutex<Option<JsonFileStorage>>>,
}
impl LuaPlugin {
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
Self::load_with_options(path, LuaPluginOptions::default())
}
pub fn from_string(script: &str, name: &str) -> Result<Self> {
Self::from_string_with_options(script, name, LuaPluginOptions::default())
}
pub fn load_with_options<P: AsRef<Path>>(path: P, options: LuaPluginOptions) -> Result<Self> {
let script = std::fs::read_to_string(path.as_ref())?;
Self::from_string_with_options(&script, "<file>", options)
}
pub fn from_string_with_options(
script: &str,
_name: &str,
options: LuaPluginOptions,
) -> Result<Self> {
let libs = StdLib::TABLE
| StdLib::STRING
| StdLib::MATH
| StdLib::OS
| StdLib::PACKAGE
| StdLib::BIT
| StdLib::JIT;
let lua_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
Lua::new_with(libs, LuaOptions::default())
}));
let lua = match lua_result {
Ok(Ok(lua)) => lua,
Ok(Err(e)) => return Err(anyhow::anyhow!("Failed to construct sandboxed Lua VM: {}", e)),
Err(panic_info) => {
let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = panic_info.downcast_ref::<String>() {
s.clone()
} else {
"Unknown panic".to_string()
};
return Err(anyhow::anyhow!("Lua VM construction panicked: {}", msg));
}
};
if options.memory_mb > 0 {
let bytes = (options.memory_mb as usize).saturating_mul(1024 * 1024);
let _ = lua.set_memory_limit(bytes);
}
let storage = Arc::new(Mutex::new(None));
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
setup_safe_stdlib(&lua, &options, storage.clone())
})).map_err(|e| {
let msg = if let Some(s) = e.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = e.downcast_ref::<String>() {
s.clone()
} else {
"Unknown panic".to_string()
};
anyhow::anyhow!("Failed to set up sandboxed stdlib (panicked): {}", msg)
})?.map_err(|e| anyhow::anyhow!("Failed to set up sandboxed stdlib: {}", e))?;
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
neutralise_dangerous(&lua, &options)
})).map_err(|e| {
let msg = if let Some(s) = e.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = e.downcast_ref::<String>() {
s.clone()
} else {
"Unknown panic".to_string()
};
anyhow::anyhow!("Failed to neutralise dangerous globals (panicked): {}", msg)
})?.map_err(|e| anyhow::anyhow!("Failed to neutralise dangerous globals: {}", e))?;
lua.load(script)
.exec()
.map_err(|e| anyhow::anyhow!("Plugin script error: {e}"))?;
let metadata = Self::extract_metadata(&lua)?;
if options.max_instructions > 0 {
install_instruction_hook(&lua, options.max_instructions)?;
}
Ok(Self {
lua,
metadata,
context: None,
options,
storage,
})
}
fn extract_metadata(lua: &Lua) -> Result<PluginMetadata> {
let globals = lua.globals();
let plugin_table: Table = globals
.get("plugin")
.map_err(|e| anyhow::anyhow!("Plugin must define a 'plugin' table: {e}"))?;
let name: String = plugin_table
.get("name")
.map_err(|e| anyhow::anyhow!("Plugin metadata missing 'name': {e}"))?;
let version: String = plugin_table
.get("version")
.map_err(|e| anyhow::anyhow!("Plugin metadata missing 'version': {e}"))?;
let author: String = plugin_table
.get("author")
.map_err(|e| anyhow::anyhow!("Plugin metadata missing 'author': {e}"))?;
let description: String = plugin_table.get("description").unwrap_or_default();
let hooks_table: Table = plugin_table
.get("hooks")
.map_err(|e| anyhow::anyhow!("Plugin metadata missing 'hooks' table: {e}"))?;
let mut hooks = Vec::new();
for pair in hooks_table.pairs::<String, bool>() {
let (hook_name, enabled) = pair
.map_err(|e| anyhow::anyhow!("Failed to iterate hooks: {e}"))?;
if !enabled {
continue;
}
if let Some(h) = hook_name_to_enum(&hook_name) {
hooks.push(h);
}
}
Ok(PluginMetadata {
name,
version,
author,
description,
hooks,
})
}
fn call_lua_hook(
&self,
hook_name: &str,
data: &serde_json::Value,
) -> Result<serde_json::Value> {
let globals = self.lua.globals();
let lua_data = self.json_to_lua(data)?;
let hook_fn: mlua::Function = globals
.get(hook_name)
.map_err(|e| anyhow::anyhow!("Hook function '{hook_name}' not found: {e}"))?;
let result: Value = hook_fn
.call(lua_data)
.map_err(|e| anyhow::anyhow!("Lua hook '{hook_name}' raised: {e}"))?;
self.lua_to_json(&result)
}
fn json_to_lua(&self, value: &serde_json::Value) -> Result<Value> {
Ok(self
.lua
.to_value(value)
.map_err(|e| anyhow::anyhow!("Failed to convert JSON to Lua: {e}"))?)
}
fn lua_to_json(&self, value: &Value) -> Result<serde_json::Value> {
Ok(self
.lua
.from_value(value.clone())
.map_err(|e| anyhow::anyhow!("Failed to convert Lua to JSON: {e}"))?)
}
pub fn call_event(&self, event: &serde_json::Value) -> Result<Option<serde_json::Value>> {
let globals = self.lua.globals();
let plugin_table: Table = match globals.get("plugin") {
Ok(t) => t,
Err(_) => return Ok(None),
};
let on_event_fn: mlua::Function = match plugin_table.get("on_event") {
Ok(f) => f,
Err(_) => return Ok(None),
};
let lua_event = self.json_to_lua(event)?;
let result: Value = on_event_fn
.call(lua_event)
.map_err(|e| anyhow::anyhow!("plugin.on_event failed: {e}"))?;
match result {
Value::Nil => Ok(None),
_ => Ok(Some(self.lua_to_json(&result)?)),
}
}
pub fn call_typed_event(&self, event: &PluginEvent) -> Result<Option<serde_json::Value>> {
let v = serde_json::to_value(event)?;
self.call_event(&v)
}
fn call_highlight(
&self,
request: &HighlightRequest,
) -> Result<Option<HighlightResponse>> {
let globals = self.lua.globals();
let plugin_table: Table = match globals.get("plugin") {
Ok(t) => t,
Err(_) => return Ok(None),
};
let highlight_fn: mlua::Function = match plugin_table.get("highlight") {
Ok(f) => f,
Err(_) => return Ok(None),
};
let req_json = serde_json::to_value(request)?;
let req_lua = self.json_to_lua(&req_json)?;
let result: Value = highlight_fn
.call(req_lua)
.map_err(|e| anyhow::anyhow!("plugin.highlight failed: {e}"))?;
if let Value::Nil = result {
return Ok(None);
}
let resp_json = self.lua_to_json(&result)?;
let resp: HighlightResponse = serde_json::from_value(resp_json)
.map_err(|e| anyhow::anyhow!("highlight response decode: {e}"))?;
if resp.spans.is_empty() {
Ok(None)
} else {
Ok(Some(resp))
}
}
}
impl Plugin for LuaPlugin {
fn metadata(&self) -> &PluginMetadata {
&self.metadata
}
fn init(&mut self, context: &PluginContext) -> PluginResult<()> {
let globals = self.lua.globals();
let repo_root = self
.options
.repo_root
.clone()
.unwrap_or_else(|| PathBuf::from(&context.repo_path));
let scoped = JsonFileStorage::new(&repo_root, &self.metadata.name);
if let Ok(mut slot) = self.storage.lock() {
*slot = Some(scoped);
}
let context_clone = context.clone();
let init_result: Result<Result<(), PluginError>, String> =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let context_json = serde_json::to_value(&context_clone)
.map_err(|e| PluginError::InitError(e.to_string()))?;
let context_lua = self
.json_to_lua(&context_json)
.map_err(|e| PluginError::InitError(e.to_string()))?;
globals
.set("context", context_lua)
.map_err(|e| PluginError::InitError(e.to_string()))?;
self.context = Some(context_clone.clone());
if let Ok(init_fn) = globals.get::<mlua::Function>("init") {
init_fn
.call::<()>(())
.map_err(|e| PluginError::InitError(e.to_string()))?;
}
Ok(())
})).map_err(|panic_payload| {
if let Some(s) = panic_payload.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = panic_payload.downcast_ref::<String>() {
s.clone()
} else {
"Unknown panic in init".to_string()
}
});
match init_result {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(e),
Err(msg) => Err(PluginError::InitError(format!("Plugin init panicked: {}", msg))),
}
}
fn execute_hook(
&mut self,
hook: &PluginHook,
data: &serde_json::Value,
) -> PluginResult<serde_json::Value> {
if !self.supports_hook(hook) {
return Err(PluginError::UnsupportedHook(hook.clone()));
}
let result = match hook {
PluginHook::OnSchedule(name) => {
self.call_lua_hook(&format!("on_schedule_{name}"), data)
}
PluginHook::OnSprintStart(n) => self.call_lua_hook(
"on_sprint_start",
&serde_json::json!({ "sprint": n, "data": data }),
),
PluginHook::OnSprintEnd(n) => self.call_lua_hook(
"on_sprint_end",
&serde_json::json!({ "sprint": n, "data": data }),
),
PluginHook::OnCommand(_cmd) => self.call_lua_hook("on_command", data),
PluginHook::OnBulkOperation(op) => {
let name = match op {
BulkOp::Import => "on_bulk_import",
BulkOp::Export => "on_bulk_export",
BulkOp::Archive => "on_bulk_archive",
BulkOp::Delete => "on_bulk_delete",
};
self.call_lua_hook(name, data)
}
other => {
let name = hook_enum_to_name(other).ok_or_else(|| {
PluginError::ExecutionError(format!("no Lua name for hook {other:?}"))
})?;
self.call_lua_hook(name, data)
}
};
result.map_err(|e| PluginError::ExecutionError(e.to_string()))
}
fn on_event(&mut self, event: &serde_json::Value) -> PluginResult<Option<serde_json::Value>> {
self.call_event(event)
.map_err(|e| PluginError::ExecutionError(e.to_string()))
}
fn highlight(
&mut self,
request: &HighlightRequest,
) -> PluginResult<Option<HighlightResponse>> {
self.call_highlight(request)
.map_err(|e| PluginError::ExecutionError(e.to_string()))
}
}
impl IssuePlugin for LuaPlugin {}
impl SyncPlugin for LuaPlugin {}
fn hook_name_to_enum(name: &str) -> Option<PluginHook> {
Some(match name {
"on_issue_created" => PluginHook::OnIssueCreated,
"on_issue_updated" => PluginHook::OnIssueUpdated,
"on_issue_deleted" => PluginHook::OnIssueDeleted,
"on_status_changed" => PluginHook::OnStatusChanged,
"on_sync_push" => PluginHook::OnSyncPush,
"on_sync_pull" => PluginHook::OnSyncPull,
"on_merge_request_created" => PluginHook::OnMergeRequestCreated,
"on_external_sync" => PluginHook::OnExternalSync,
"on_webhook_received" => PluginHook::OnWebhookReceived,
"on_due_date_approaching" => PluginHook::OnDueDateApproaching,
"on_due_date_passed" => PluginHook::OnDueDatePassed,
"on_report_requested" => PluginHook::OnReportRequested,
"on_metric_query" => PluginHook::OnMetricQuery,
"on_command" => PluginHook::OnCommand(String::new()), _ => return None,
})
}
fn hook_enum_to_name(hook: &PluginHook) -> Option<&'static str> {
Some(match hook {
PluginHook::OnIssueCreated => "on_issue_created",
PluginHook::OnIssueUpdated => "on_issue_updated",
PluginHook::OnIssueDeleted => "on_issue_deleted",
PluginHook::OnStatusChanged => "on_status_changed",
PluginHook::OnSyncPush => "on_sync_push",
PluginHook::OnSyncPull => "on_sync_pull",
PluginHook::OnMergeRequestCreated => "on_merge_request_created",
PluginHook::OnExternalSync => "on_external_sync",
PluginHook::OnWebhookReceived => "on_webhook_received",
PluginHook::OnDueDateApproaching => "on_due_date_approaching",
PluginHook::OnDueDatePassed => "on_due_date_passed",
PluginHook::OnReportRequested => "on_report_requested",
PluginHook::OnMetricQuery => "on_metric_query",
PluginHook::OnCommand(_) => "on_command", _ => return None,
})
}
fn install_instruction_hook(lua: &Lua, max: u64) -> Result<()> {
use std::sync::atomic::{AtomicU64, Ordering};
let counter = Arc::new(AtomicU64::new(0));
let triggers = HookTriggers::new().every_nth_instruction(4096);
let counter_cl = counter.clone();
lua.set_hook(triggers, move |_, _debug| {
let n = counter_cl.fetch_add(4096, Ordering::Relaxed);
if n >= max {
return Err(mlua::Error::RuntimeError(format!(
"plugin exceeded instruction budget ({max})"
)));
}
Ok(mlua::VmState::Continue)
})
.map_err(|e| anyhow::anyhow!("Failed to install Lua instruction hook: {e}"))?;
Ok(())
}
fn setup_safe_stdlib(
lua: &Lua,
opts: &LuaPluginOptions,
storage: Arc<Mutex<Option<JsonFileStorage>>>,
) -> mlua::Result<()> {
let globals = lua.globals();
let http_cap = Arc::new(HttpCap::new(opts)?);
let http = lua.create_table()?;
let cap = http_cap.clone();
let http_get = lua.create_function(move |lua, (url, headers): (String, Option<Table>)| {
cap.check(&url)?;
let mut req = cap.client.get(&url);
if let Some(h) = headers {
for pair in h.pairs::<String, String>() {
let (k, v) = pair?;
req = req.header(k, v);
}
}
send_and_wrap(lua, req)
})?;
let cap = http_cap.clone();
let http_post = lua.create_function(
move |lua, (url, body, headers): (String, String, Option<Table>)| {
cap.check(&url)?;
let mut req = cap.client.post(&url).body(body);
if let Some(h) = headers {
for pair in h.pairs::<String, String>() {
let (k, v) = pair?;
req = req.header(k, v);
}
}
send_and_wrap(lua, req)
},
)?;
let cap = http_cap.clone();
let http_put = lua.create_function(
move |lua, (url, body, headers): (String, String, Option<Table>)| {
cap.check(&url)?;
let mut req = cap.client.put(&url).body(body);
if let Some(h) = headers {
for pair in h.pairs::<String, String>() {
let (k, v) = pair?;
req = req.header(k, v);
}
}
send_and_wrap(lua, req)
},
)?;
let cap = http_cap.clone();
let http_delete =
lua.create_function(move |lua, (url, headers): (String, Option<Table>)| {
cap.check(&url)?;
let mut req = cap.client.delete(&url);
if let Some(h) = headers {
for pair in h.pairs::<String, String>() {
let (k, v) = pair?;
req = req.header(k, v);
}
}
send_and_wrap(lua, req)
})?;
http.set("get", http_get)?;
http.set("post", http_post)?;
http.set("put", http_put)?;
http.set("delete", http_delete)?;
globals.set("http", http)?;
let json = lua.create_table()?;
let json_encode = lua.create_function(|lua, value: Value| {
let json_val: serde_json::Value = lua
.from_value(value)
.map_err(|e| mlua::Error::RuntimeError(format!("json.encode: {e}")))?;
serde_json::to_string(&json_val)
.map_err(|e| mlua::Error::RuntimeError(format!("json.encode: {e}")))
})?;
let json_decode = lua.create_function(|lua, s: String| {
let json_val: serde_json::Value = serde_json::from_str(&s)
.map_err(|e| mlua::Error::RuntimeError(format!("json.decode: {e}")))?;
lua.to_value(&json_val)
.map_err(|e| mlua::Error::RuntimeError(format!("json.decode: {e}")))
})?;
json.set("encode", json_encode)?;
json.set("decode", json_decode)?;
globals.set("json", json)?;
let log = lua.create_table()?;
let log_debug = lua.create_function(|_, msg: String| {
log::debug!(target: "progit_plugin", "{msg}");
Ok(())
})?;
let log_info = lua.create_function(|_, msg: String| {
log::info!(target: "progit_plugin", "{msg}");
Ok(())
})?;
let log_warn = lua.create_function(|_, msg: String| {
log::warn!(target: "progit_plugin", "{msg}");
Ok(())
})?;
let log_error = lua.create_function(|_, msg: String| {
log::error!(target: "progit_plugin", "{msg}");
Ok(())
})?;
log.set("debug", log_debug)?;
log.set("info", log_info)?;
log.set("warn", log_warn)?;
log.set("error", log_error)?;
globals.set("log", log)?;
let log_info_global = lua.create_function(|_, msg: String| {
log::info!(target: "progit_plugin", "{msg}");
Ok(())
})?;
let log_warn_global = lua.create_function(|_, msg: String| {
log::warn!(target: "progit_plugin", "{msg}");
Ok(())
})?;
let log_error_global = lua.create_function(|_, msg: String| {
log::error!(target: "progit_plugin", "{msg}");
Ok(())
})?;
globals.set("log_info", log_info_global)?;
globals.set("log_warn", log_warn_global)?;
globals.set("log_error", log_error_global)?;
let sober_tbl = lua.create_table()?;
if let Some(host) = opts.sober.clone() {
let sober_run = lua.create_function(
move |lua, (action, options): (String, Option<Value>)| {
let options_json = match options {
Some(value) => lua.from_value(value).map_err(|e| {
mlua::Error::RuntimeError(format!("sober.run: options: {e}"))
})?,
None => serde_json::Value::Object(Default::default()),
};
let result = host.run(SoberInvocation {
action,
options: options_json,
});
lua.to_value(&result)
.map_err(|e| mlua::Error::RuntimeError(format!("sober.run: result: {e}")))
},
)?;
sober_tbl.set("run", sober_run)?;
} else {
let sober_run = lua.create_function(|_, (_action, _options): (String, Option<Value>)| {
Err::<Value, _>(mlua::Error::RuntimeError(
"sober.run requires the manifest capability `sober = true` and a host bridge"
.into(),
))
})?;
sober_tbl.set("run", sober_run)?;
}
globals.set("sober", sober_tbl)?;
let storage_tbl = lua.create_table()?;
let s = storage.clone();
let storage_get = lua.create_function(move |lua, key: String| {
let guard = s
.lock()
.map_err(|_| mlua::Error::RuntimeError("storage poisoned".into()))?;
let val = match guard.as_ref() {
Some(st) => st
.get(&key)
.map_err(|e| mlua::Error::RuntimeError(e.to_string()))?,
None => return Ok(Value::Nil),
};
match val {
Some(v) => lua
.to_value(&v)
.map_err(|e| mlua::Error::RuntimeError(e.to_string())),
None => Ok(Value::Nil),
}
})?;
let s = storage.clone();
let storage_set = lua.create_function(move |lua, (key, value): (String, Value)| {
let json_val: serde_json::Value = lua
.from_value(value)
.map_err(|e| mlua::Error::RuntimeError(format!("storage.set: {e}")))?;
let mut guard = s
.lock()
.map_err(|_| mlua::Error::RuntimeError("storage poisoned".into()))?;
match guard.as_mut() {
Some(st) => st
.set(&key, &json_val)
.map_err(|e| mlua::Error::RuntimeError(e.to_string()))?,
None => return Err(mlua::Error::RuntimeError("storage not initialised".into())),
}
Ok(())
})?;
let s = storage.clone();
let storage_delete = lua.create_function(move |_, key: String| {
let mut guard = s
.lock()
.map_err(|_| mlua::Error::RuntimeError("storage poisoned".into()))?;
let removed = match guard.as_mut() {
Some(st) => st
.delete(&key)
.map_err(|e| mlua::Error::RuntimeError(e.to_string()))?,
None => false,
};
Ok(removed)
})?;
let s = storage.clone();
let storage_keys = lua.create_function(move |lua, ()| {
let guard = s
.lock()
.map_err(|_| mlua::Error::RuntimeError("storage poisoned".into()))?;
let keys: Vec<String> = match guard.as_ref() {
Some(st) => st
.keys()
.map_err(|e| mlua::Error::RuntimeError(e.to_string()))?,
None => Vec::new(),
};
let t = lua.create_table()?;
for (i, k) in keys.into_iter().enumerate() {
t.set(i + 1, k)?;
}
Ok(t)
})?;
let storage_clear = {
let s = storage.clone();
lua.create_function(move |_, ()| {
let mut guard = s
.lock()
.map_err(|_| mlua::Error::RuntimeError("storage poisoned".into()))?;
if let Some(st) = guard.as_mut() {
st.clear()
.map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
}
Ok(())
})?
};
storage_tbl.set("get", storage_get)?;
storage_tbl.set("set", storage_set)?;
storage_tbl.set("delete", storage_delete)?;
storage_tbl.set("keys", storage_keys)?;
storage_tbl.set("clear", storage_clear)?;
globals.set("storage", storage_tbl)?;
let package: Table = globals.get("package")?;
let loaded: Table = package.get("loaded")?;
for name in ["http", "json", "log", "sober", "storage"] {
let v: Value = globals.get(name)?;
loaded.set(name, v)?;
}
let repo_root = opts.repo_root.clone();
let io = lua.create_table()?;
let repo_for_open = repo_root.clone();
let io_open = lua.create_function(move |lua, (path, mode): (String, Option<String>)| {
let repo_str = repo_for_open.as_ref()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| ".".to_string());
let m = mode.unwrap_or_else(|| "r".to_string());
if !m.starts_with('r') && !m.starts_with('a') {
return Err(mlua::Error::RuntimeError(
"io: only read ('r') and append ('a') modes are allowed".into()
));
}
let abs = if path.starts_with('/') { path.clone() }
else { format!("{}/{}", repo_str, path) };
let content = match std::fs::read_to_string(&abs) {
Ok(c) => c,
Err(e) => return Err(mlua::Error::RuntimeError(format!("io: {}", e))),
};
let file = lua.create_table()?;
let c = content.clone();
let read_fn = lua.create_function(move |_, fmt: String| {
if fmt == "*a" { Ok(c.clone()) }
else if fmt == "*l" { Ok(c.lines().next().unwrap_or_default().to_string()) }
else { Ok(c.clone()) }
})?;
file.set("read", read_fn)?;
let close_fn = lua.create_function(|_, ()| Ok(()))?;
file.set("close", close_fn)?;
let lines_vec: Vec<String> = content.lines().map(|s| s.to_string()).collect();
let lines_fn = lua.create_function(move |lua, ()| {
let lines = lines_vec.clone();
let idx = std::cell::RefCell::new(0usize);
Ok(lua.create_function(move |_, ()| {
*idx.borrow_mut() += 1;
let i = *idx.borrow();
if i <= lines.len() { Ok(Some(lines[i-1].clone())) }
else { Ok(None) }
}))
})?;
file.set("lines", lines_fn)?;
Ok(file)
})?;
io.set("open", io_open.clone())?;
globals.set("io", io)?;
globals.set("io_open", io_open)?;
Ok(())
}
fn send_and_wrap(lua: &Lua, req: reqwest::blocking::RequestBuilder) -> mlua::Result<Table> {
let resp = req
.send()
.map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
let status = resp.status().as_u16() as i64;
let ok = resp.status().is_success();
let body = resp
.text()
.map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
let t = lua.create_table()?;
t.set("status", status)?;
t.set("body", body)?;
t.set("ok", ok)?;
Ok(t)
}
fn neutralise_dangerous(lua: &Lua, opts: &LuaPluginOptions) -> mlua::Result<()> {
let globals = lua.globals();
if let Ok(os_tbl) = globals.get::<Table>("os") {
for name in ["execute", "exit", "remove", "rename", "tmpname", "setlocale"] {
let banned = name.to_string();
let f = lua.create_function(move |_, ()| {
Err::<(), _>(mlua::Error::RuntimeError(format!(
"os.{banned} is disabled in the ProGit plugin sandbox"
)))
})?;
os_tbl.set(name, f)?;
}
if !opts.env_access {
let f = lua.create_function(|_, _name: String| {
Err::<Option<String>, _>(mlua::Error::RuntimeError(
"os.getenv is disabled (capability 'env' not granted)".into(),
))
})?;
os_tbl.set("getenv", f)?;
}
}
if let Ok(pkg_tbl) = globals.get::<Table>("package") {
for name in ["loadlib", "searchpath"] {
let banned = name.to_string();
let f = lua.create_function(move |_, ()| {
Err::<(), _>(mlua::Error::RuntimeError(format!(
"package.{banned} is disabled in the sandbox"
)))
})?;
pkg_tbl.set(name, f)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn loose_opts() -> LuaPluginOptions {
let mut opts = LuaPluginOptions::default();
opts.network = false;
opts.max_instructions = 0;
opts
}
#[test]
fn loads_minimal_plugin() {
let script = r#"
plugin = {
name = "test", version = "1.0.0", author = "t",
hooks = { on_issue_created = true }
}
function on_issue_created(issue)
return { ok = true }
end
"#;
let p = LuaPlugin::from_string_with_options(script, "test", loose_opts()).unwrap();
assert_eq!(p.metadata().name, "test");
}
#[test]
fn os_execute_is_blocked() {
let script = r#"
plugin = { name="x", version="1", author="y", hooks = {} }
os.execute("ls")
"#;
let err = LuaPlugin::from_string_with_options(script, "x", loose_opts())
.err()
.expect("os.execute should be blocked");
let msg = format!("{err}");
assert!(msg.contains("disabled"), "got: {msg}");
}
#[test]
fn io_module_shim_works() {
let script = r#"
plugin = { name="x", version="1", author="y", hooks = {} }
assert(type(io) == "table", "io should be a table")
assert(type(io.open) == "function", "io.open should be a function")
"#;
LuaPlugin::from_string_with_options(script, "x", loose_opts()).unwrap();
}
#[test]
fn debug_module_is_absent() {
let script = r#"
plugin = { name="x", version="1", author="y", hooks = {} }
assert(debug == nil, "debug should be nil")
"#;
LuaPlugin::from_string_with_options(script, "x", loose_opts()).unwrap();
}
#[test]
fn http_blocked_without_network_capability() {
let script = r#"
plugin = { name="x", version="1", author="y", hooks = { on_issue_created = true } }
function on_issue_created(_)
local r = http.get("https://example.com")
return { ok = r.ok }
end
"#;
let opts = loose_opts();
let mut p = LuaPlugin::from_string_with_options(script, "x", opts).unwrap();
let ctx = PluginContext {
repo_path: std::env::temp_dir().to_string_lossy().to_string(),
user: None,
env: Default::default(),
config: Default::default(),
};
p.init(&ctx).unwrap();
let err = p
.execute_hook(&PluginHook::OnIssueCreated, &serde_json::json!({}))
.err()
.expect("network should be denied");
assert!(format!("{err}").contains("network"));
}
#[test]
fn storage_round_trip() {
let script = r#"
plugin = { name="store-test", version="1", author="t",
hooks = { on_issue_created = true } }
function on_issue_created(_)
storage.set("k", { v = 42 })
local got = storage.get("k")
return { v = got.v }
end
"#;
let temp = tempfile::tempdir().unwrap();
let mut opts = loose_opts();
opts.repo_root = Some(temp.path().to_path_buf());
let mut p = LuaPlugin::from_string_with_options(script, "store-test", opts).unwrap();
let ctx = PluginContext {
repo_path: temp.path().to_string_lossy().to_string(),
user: None,
env: Default::default(),
config: Default::default(),
};
p.init(&ctx).unwrap();
let r = p
.execute_hook(&PluginHook::OnIssueCreated, &serde_json::json!({}))
.unwrap();
assert_eq!(r["v"], 42);
}
#[test]
fn sober_capability_is_blocked_without_host() {
let script = r#"
plugin = { name="sober-denied", version="1", author="t",
hooks = { on_issue_created = true } }
function on_issue_created(_)
return sober.run("doctor", {})
end
"#;
let mut p = LuaPlugin::from_string_with_options(script, "sober-denied", loose_opts()).unwrap();
let ctx = PluginContext {
repo_path: std::env::temp_dir().to_string_lossy().to_string(),
user: None,
env: Default::default(),
config: Default::default(),
};
p.init(&ctx).unwrap();
let err = p
.execute_hook(&PluginHook::OnIssueCreated, &serde_json::json!({}))
.err()
.expect("sober should require host capability");
assert!(format!("{err}").contains("sober.run requires"));
}
#[test]
fn sober_capability_round_trips_through_host() {
let script = r#"
plugin = { name="sober-ok", version="1", author="t",
hooks = { on_issue_created = true } }
function on_issue_created(_)
return sober.run("preflight", { base = "HEAD" })
end
"#;
let mut opts = loose_opts();
opts.sober = Some(SoberHost::new(|invocation| {
assert_eq!(invocation.action, "preflight");
assert_eq!(invocation.options["base"], "HEAD");
SoberInvocationResult {
ok: true,
data: serde_json::json!({ "action": invocation.action, "ok": true }),
error: None,
}
}));
let mut p = LuaPlugin::from_string_with_options(script, "sober-ok", opts).unwrap();
let ctx = PluginContext {
repo_path: std::env::temp_dir().to_string_lossy().to_string(),
user: None,
env: Default::default(),
config: Default::default(),
};
p.init(&ctx).unwrap();
let r = p
.execute_hook(&PluginHook::OnIssueCreated, &serde_json::json!({}))
.unwrap();
assert_eq!(r["ok"], true);
assert_eq!(r["data"]["action"], "preflight");
}
#[test]
fn instruction_cap_trips_runaway_loop() {
let script = r#"
jit.off()
plugin = { name="loopy", version="1", author="t",
hooks = { on_issue_created = true } }
function on_issue_created(_)
local i = 0
while true do i = i + 1 end
return { i = i }
end
"#;
let mut opts = loose_opts();
opts.max_instructions = 100_000;
let mut p = LuaPlugin::from_string_with_options(script, "loopy", opts).unwrap();
let ctx = PluginContext {
repo_path: std::env::temp_dir().to_string_lossy().to_string(),
user: None,
env: Default::default(),
config: Default::default(),
};
p.init(&ctx).unwrap();
let err = p
.execute_hook(&PluginHook::OnIssueCreated, &serde_json::json!({}))
.err()
.expect("runaway loop should hit the instruction cap");
assert!(format!("{err}").contains("instruction budget"), "{err}");
}
#[test]
fn highlight_round_trips_through_lua() {
let script = r#"
plugin = { name="hl", version="1", author="t", hooks = {} }
function plugin.highlight(req)
return {
spans = {
{ text = "fn ", fg = { r = 200, g = 0, b = 0 }, bold = true },
{ text = req.content:sub(4) },
}
}
end
"#;
let mut p = LuaPlugin::from_string_with_options(script, "hl", loose_opts()).unwrap();
let ctx = PluginContext {
repo_path: std::env::temp_dir().to_string_lossy().to_string(),
user: None,
env: Default::default(),
config: Default::default(),
};
p.init(&ctx).unwrap();
let resp = p
.highlight(&HighlightRequest {
language: Some("rust".into()),
content: "fn main() {}".into(),
})
.expect("highlight should not error")
.expect("plugin should return spans");
assert_eq!(resp.spans.len(), 2);
assert_eq!(resp.spans[0].text, "fn ");
assert_eq!(resp.spans[0].fg, Some(crate::render::Rgb::new(200, 0, 0)));
assert!(resp.spans[0].bold);
assert_eq!(resp.spans[1].text, "main() {}");
}
#[test]
fn highlight_returns_none_when_plugin_does_not_implement_it() {
let script = r#"
plugin = { name="nohl", version="1", author="t", hooks = {} }
"#;
let mut p = LuaPlugin::from_string_with_options(script, "nohl", loose_opts()).unwrap();
let ctx = PluginContext {
repo_path: std::env::temp_dir().to_string_lossy().to_string(),
user: None,
env: Default::default(),
config: Default::default(),
};
p.init(&ctx).unwrap();
let resp = p
.highlight(&HighlightRequest {
language: None,
content: "x".into(),
})
.unwrap();
assert!(resp.is_none(), "plugin without highlight() should yield None");
}
#[test]
fn back_compat_global_log_info_works() {
let script = r#"
plugin = { name="bc", version="1", author="t",
hooks = { on_issue_created = true } }
function on_issue_created(_)
log_info("hello from v0.1 style")
return { ok = true }
end
"#;
let mut p = LuaPlugin::from_string_with_options(script, "bc", loose_opts()).unwrap();
let ctx = PluginContext {
repo_path: std::env::temp_dir().to_string_lossy().to_string(),
user: None,
env: Default::default(),
config: Default::default(),
};
p.init(&ctx).unwrap();
let r = p
.execute_hook(&PluginHook::OnIssueCreated, &serde_json::json!({}))
.unwrap();
assert_eq!(r["ok"], true);
}
}