1use crate::event::PluginEvent;
17use crate::manifest::Capabilities;
18use crate::render::{HighlightRequest, HighlightResponse};
19use crate::storage::{JsonFileStorage, PluginStorage};
20use crate::traits::*;
21use anyhow::Result;
22use mlua::{HookTriggers, Lua, LuaOptions, LuaSerdeExt, StdLib, Table, Value};
23use std::path::{Path, PathBuf};
24use std::sync::{Arc, Mutex};
25use std::time::Duration;
26
27#[derive(Debug, Clone)]
29pub struct SoberInvocation {
30 pub action: String,
32 pub options: serde_json::Value,
34}
35
36#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
38pub struct SoberInvocationResult {
39 pub ok: bool,
40 #[serde(default)]
41 pub data: serde_json::Value,
42 #[serde(default)]
43 pub error: Option<String>,
44}
45
46#[derive(Clone)]
48pub struct SoberHost {
49 runner: Arc<dyn Fn(SoberInvocation) -> SoberInvocationResult + Send + Sync>,
50}
51
52impl std::fmt::Debug for SoberHost {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 f.debug_struct("SoberHost").finish_non_exhaustive()
55 }
56}
57
58impl SoberHost {
59 pub fn new<F>(runner: F) -> Self
61 where
62 F: Fn(SoberInvocation) -> SoberInvocationResult + Send + Sync + 'static,
63 {
64 Self {
65 runner: Arc::new(runner),
66 }
67 }
68
69 pub fn run(&self, invocation: SoberInvocation) -> SoberInvocationResult {
71 (self.runner)(invocation)
72 }
73}
74
75#[derive(Debug, Clone)]
87pub struct LuaPluginOptions {
88 pub memory_mb: u32,
90 pub max_instructions: u64,
93 pub http_timeout: Duration,
95 pub network_allow: Vec<String>,
97 pub network: bool,
99 pub env_access: bool,
101 pub sober: Option<SoberHost>,
103 pub repo_root: Option<PathBuf>,
105}
106
107impl Default for LuaPluginOptions {
108 fn default() -> Self {
109 Self::from_capabilities(&Capabilities::legacy_default())
110 }
111}
112
113impl LuaPluginOptions {
114 pub fn from_capabilities(caps: &Capabilities) -> Self {
116 Self {
117 memory_mb: caps.memory_mb,
118 max_instructions: caps.max_instructions,
119 http_timeout: Duration::from_secs(caps.http_timeout_secs),
120 network_allow: caps.network_allow.clone(),
121 network: caps.network,
122 env_access: caps.env,
123 sober: None,
124 repo_root: None,
125 }
126 }
127}
128
129struct HttpCap {
131 client: reqwest::blocking::Client,
132 network: bool,
133 allow: Vec<String>,
134}
135
136impl HttpCap {
137 fn new(opts: &LuaPluginOptions) -> mlua::Result<Self> {
138 let client = reqwest::blocking::Client::builder()
139 .timeout(opts.http_timeout)
140 .user_agent(concat!("progit-plugin-sdk/", env!("CARGO_PKG_VERSION")))
141 .build()
142 .map_err(|e| mlua::Error::RuntimeError(format!("http client init: {e}")))?;
143 Ok(Self {
144 client,
145 network: opts.network,
146 allow: opts.network_allow.clone(),
147 })
148 }
149
150 fn check(&self, url: &str) -> mlua::Result<()> {
151 if !self.network {
152 return Err(mlua::Error::RuntimeError(
153 "http: capability 'network' not granted in plugin manifest".into(),
154 ));
155 }
156 if self.allow.is_empty() {
157 return Ok(());
158 }
159 let host = match reqwest::Url::parse(url).ok().and_then(|u| u.host_str().map(str::to_string)) {
160 Some(h) => h.to_lowercase(),
161 None => {
162 return Err(mlua::Error::RuntimeError(format!("http: invalid URL '{url}'")));
163 }
164 };
165 let ok = self.allow.iter().any(|a| {
166 let al = a.to_lowercase();
167 host == al || host.ends_with(&format!(".{al}"))
168 });
169 if ok {
170 Ok(())
171 } else {
172 Err(mlua::Error::RuntimeError(format!(
173 "http: host '{host}' not in network_allow list"
174 )))
175 }
176 }
177}
178
179pub struct LuaPlugin {
181 lua: Lua,
182 metadata: PluginMetadata,
183 context: Option<PluginContext>,
184 options: LuaPluginOptions,
185 storage: Arc<Mutex<Option<JsonFileStorage>>>,
187}
188
189impl LuaPlugin {
190 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
192 Self::load_with_options(path, LuaPluginOptions::default())
193 }
194
195 pub fn from_string(script: &str, name: &str) -> Result<Self> {
197 Self::from_string_with_options(script, name, LuaPluginOptions::default())
198 }
199
200 pub fn load_with_options<P: AsRef<Path>>(path: P, options: LuaPluginOptions) -> Result<Self> {
203 let script = std::fs::read_to_string(path.as_ref())?;
204 Self::from_string_with_options(&script, "<file>", options)
205 }
206
207 pub fn from_string_with_options(
209 script: &str,
210 _name: &str,
211 options: LuaPluginOptions,
212 ) -> Result<Self> {
213 let libs = StdLib::TABLE
222 | StdLib::STRING
223 | StdLib::MATH
224 | StdLib::OS
225 | StdLib::PACKAGE
226 | StdLib::BIT
227 | StdLib::JIT;
228
229 let lua_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
231 Lua::new_with(libs, LuaOptions::default())
232 }));
233
234 let lua = match lua_result {
235 Ok(Ok(lua)) => lua,
236 Ok(Err(e)) => return Err(anyhow::anyhow!("Failed to construct sandboxed Lua VM: {}", e)),
237 Err(panic_info) => {
238 let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
239 s.to_string()
240 } else if let Some(s) = panic_info.downcast_ref::<String>() {
241 s.clone()
242 } else {
243 "Unknown panic".to_string()
244 };
245 return Err(anyhow::anyhow!("Lua VM construction panicked: {}", msg));
246 }
247 };
248
249 if options.memory_mb > 0 {
252 let bytes = (options.memory_mb as usize).saturating_mul(1024 * 1024);
253 let _ = lua.set_memory_limit(bytes);
254 }
255
256 let storage = Arc::new(Mutex::new(None));
257
258 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
260 setup_safe_stdlib(&lua, &options, storage.clone())
261 })).map_err(|e| {
262 let msg = if let Some(s) = e.downcast_ref::<&str>() {
263 s.to_string()
264 } else if let Some(s) = e.downcast_ref::<String>() {
265 s.clone()
266 } else {
267 "Unknown panic".to_string()
268 };
269 anyhow::anyhow!("Failed to set up sandboxed stdlib (panicked): {}", msg)
270 })?.map_err(|e| anyhow::anyhow!("Failed to set up sandboxed stdlib: {}", e))?;
271
272 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
273 neutralise_dangerous(&lua, &options)
274 })).map_err(|e| {
275 let msg = if let Some(s) = e.downcast_ref::<&str>() {
276 s.to_string()
277 } else if let Some(s) = e.downcast_ref::<String>() {
278 s.clone()
279 } else {
280 "Unknown panic".to_string()
281 };
282 anyhow::anyhow!("Failed to neutralise dangerous globals (panicked): {}", msg)
283 })?.map_err(|e| anyhow::anyhow!("Failed to neutralise dangerous globals: {}", e))?;
284
285 lua.load(script)
286 .exec()
287 .map_err(|e| anyhow::anyhow!("Plugin script error: {e}"))?;
288
289 let metadata = Self::extract_metadata(&lua)?;
290
291 if options.max_instructions > 0 {
292 install_instruction_hook(&lua, options.max_instructions)?;
293 }
294
295 Ok(Self {
296 lua,
297 metadata,
298 context: None,
299 options,
300 storage,
301 })
302 }
303
304 fn extract_metadata(lua: &Lua) -> Result<PluginMetadata> {
305 let globals = lua.globals();
306 let plugin_table: Table = globals
307 .get("plugin")
308 .map_err(|e| anyhow::anyhow!("Plugin must define a 'plugin' table: {e}"))?;
309
310 let name: String = plugin_table
311 .get("name")
312 .map_err(|e| anyhow::anyhow!("Plugin metadata missing 'name': {e}"))?;
313 let version: String = plugin_table
314 .get("version")
315 .map_err(|e| anyhow::anyhow!("Plugin metadata missing 'version': {e}"))?;
316 let author: String = plugin_table
317 .get("author")
318 .map_err(|e| anyhow::anyhow!("Plugin metadata missing 'author': {e}"))?;
319 let description: String = plugin_table.get("description").unwrap_or_default();
320
321 let hooks_table: Table = plugin_table
322 .get("hooks")
323 .map_err(|e| anyhow::anyhow!("Plugin metadata missing 'hooks' table: {e}"))?;
324 let mut hooks = Vec::new();
325 for pair in hooks_table.pairs::<String, bool>() {
326 let (hook_name, enabled) = pair
327 .map_err(|e| anyhow::anyhow!("Failed to iterate hooks: {e}"))?;
328 if !enabled {
329 continue;
330 }
331 if let Some(h) = hook_name_to_enum(&hook_name) {
332 hooks.push(h);
333 }
334 }
335
336 Ok(PluginMetadata {
337 name,
338 version,
339 author,
340 description,
341 hooks,
342 })
343 }
344
345 fn call_lua_hook(
346 &self,
347 hook_name: &str,
348 data: &serde_json::Value,
349 ) -> Result<serde_json::Value> {
350 let globals = self.lua.globals();
351 let lua_data = self.json_to_lua(data)?;
352 let hook_fn: mlua::Function = globals
353 .get(hook_name)
354 .map_err(|e| anyhow::anyhow!("Hook function '{hook_name}' not found: {e}"))?;
355 let result: Value = hook_fn
356 .call(lua_data)
357 .map_err(|e| anyhow::anyhow!("Lua hook '{hook_name}' raised: {e}"))?;
358 self.lua_to_json(&result)
359 }
360
361 fn json_to_lua(&self, value: &serde_json::Value) -> Result<Value> {
362 Ok(self
363 .lua
364 .to_value(value)
365 .map_err(|e| anyhow::anyhow!("Failed to convert JSON to Lua: {e}"))?)
366 }
367
368 fn lua_to_json(&self, value: &Value) -> Result<serde_json::Value> {
369 Ok(self
370 .lua
371 .from_value(value.clone())
372 .map_err(|e| anyhow::anyhow!("Failed to convert Lua to JSON: {e}"))?)
373 }
374
375 pub fn call_event(&self, event: &serde_json::Value) -> Result<Option<serde_json::Value>> {
377 let globals = self.lua.globals();
378 let plugin_table: Table = match globals.get("plugin") {
379 Ok(t) => t,
380 Err(_) => return Ok(None),
381 };
382 let on_event_fn: mlua::Function = match plugin_table.get("on_event") {
383 Ok(f) => f,
384 Err(_) => return Ok(None),
385 };
386 let lua_event = self.json_to_lua(event)?;
387 let result: Value = on_event_fn
388 .call(lua_event)
389 .map_err(|e| anyhow::anyhow!("plugin.on_event failed: {e}"))?;
390 match result {
391 Value::Nil => Ok(None),
392 _ => Ok(Some(self.lua_to_json(&result)?)),
393 }
394 }
395
396 pub fn call_typed_event(&self, event: &PluginEvent) -> Result<Option<serde_json::Value>> {
398 let v = serde_json::to_value(event)?;
399 self.call_event(&v)
400 }
401
402 fn call_highlight(
406 &self,
407 request: &HighlightRequest,
408 ) -> Result<Option<HighlightResponse>> {
409 let globals = self.lua.globals();
410 let plugin_table: Table = match globals.get("plugin") {
411 Ok(t) => t,
412 Err(_) => return Ok(None),
413 };
414 let highlight_fn: mlua::Function = match plugin_table.get("highlight") {
415 Ok(f) => f,
416 Err(_) => return Ok(None),
417 };
418 let req_json = serde_json::to_value(request)?;
419 let req_lua = self.json_to_lua(&req_json)?;
420 let result: Value = highlight_fn
421 .call(req_lua)
422 .map_err(|e| anyhow::anyhow!("plugin.highlight failed: {e}"))?;
423 if let Value::Nil = result {
424 return Ok(None);
425 }
426 let resp_json = self.lua_to_json(&result)?;
427 let resp: HighlightResponse = serde_json::from_value(resp_json)
428 .map_err(|e| anyhow::anyhow!("highlight response decode: {e}"))?;
429 if resp.spans.is_empty() {
430 Ok(None)
431 } else {
432 Ok(Some(resp))
433 }
434 }
435}
436
437impl Plugin for LuaPlugin {
438 fn metadata(&self) -> &PluginMetadata {
439 &self.metadata
440 }
441
442 fn init(&mut self, context: &PluginContext) -> PluginResult<()> {
443 let globals = self.lua.globals();
444
445 let repo_root = self
446 .options
447 .repo_root
448 .clone()
449 .unwrap_or_else(|| PathBuf::from(&context.repo_path));
450 let scoped = JsonFileStorage::new(&repo_root, &self.metadata.name);
451 if let Ok(mut slot) = self.storage.lock() {
452 *slot = Some(scoped);
453 }
454
455 let context_clone = context.clone();
457 let init_result: Result<Result<(), PluginError>, String> =
459 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
460 let context_json = serde_json::to_value(&context_clone)
461 .map_err(|e| PluginError::InitError(e.to_string()))?;
462 let context_lua = self
463 .json_to_lua(&context_json)
464 .map_err(|e| PluginError::InitError(e.to_string()))?;
465 globals
466 .set("context", context_lua)
467 .map_err(|e| PluginError::InitError(e.to_string()))?;
468
469 self.context = Some(context_clone.clone());
470
471 if let Ok(init_fn) = globals.get::<mlua::Function>("init") {
472 init_fn
473 .call::<()>(())
474 .map_err(|e| PluginError::InitError(e.to_string()))?;
475 }
476 Ok(())
477 })).map_err(|panic_payload| {
478 if let Some(s) = panic_payload.downcast_ref::<&str>() {
479 s.to_string()
480 } else if let Some(s) = panic_payload.downcast_ref::<String>() {
481 s.clone()
482 } else {
483 "Unknown panic in init".to_string()
484 }
485 });
486
487 match init_result {
488 Ok(Ok(())) => Ok(()),
489 Ok(Err(e)) => Err(e),
490 Err(msg) => Err(PluginError::InitError(format!("Plugin init panicked: {}", msg))),
491 }
492 }
493
494 fn execute_hook(
495 &mut self,
496 hook: &PluginHook,
497 data: &serde_json::Value,
498 ) -> PluginResult<serde_json::Value> {
499 if !self.supports_hook(hook) {
500 return Err(PluginError::UnsupportedHook(hook.clone()));
501 }
502 let result = match hook {
503 PluginHook::OnSchedule(name) => {
504 self.call_lua_hook(&format!("on_schedule_{name}"), data)
505 }
506 PluginHook::OnSprintStart(n) => self.call_lua_hook(
507 "on_sprint_start",
508 &serde_json::json!({ "sprint": n, "data": data }),
509 ),
510 PluginHook::OnSprintEnd(n) => self.call_lua_hook(
511 "on_sprint_end",
512 &serde_json::json!({ "sprint": n, "data": data }),
513 ),
514 PluginHook::OnCommand(_cmd) => self.call_lua_hook("on_command", data),
515 PluginHook::OnBulkOperation(op) => {
516 let name = match op {
517 BulkOp::Import => "on_bulk_import",
518 BulkOp::Export => "on_bulk_export",
519 BulkOp::Archive => "on_bulk_archive",
520 BulkOp::Delete => "on_bulk_delete",
521 };
522 self.call_lua_hook(name, data)
523 }
524 other => {
525 let name = hook_enum_to_name(other).ok_or_else(|| {
526 PluginError::ExecutionError(format!("no Lua name for hook {other:?}"))
527 })?;
528 self.call_lua_hook(name, data)
529 }
530 };
531 result.map_err(|e| PluginError::ExecutionError(e.to_string()))
532 }
533
534 fn on_event(&mut self, event: &serde_json::Value) -> PluginResult<Option<serde_json::Value>> {
535 self.call_event(event)
536 .map_err(|e| PluginError::ExecutionError(e.to_string()))
537 }
538
539 fn highlight(
540 &mut self,
541 request: &HighlightRequest,
542 ) -> PluginResult<Option<HighlightResponse>> {
543 self.call_highlight(request)
544 .map_err(|e| PluginError::ExecutionError(e.to_string()))
545 }
546}
547
548impl IssuePlugin for LuaPlugin {}
549impl SyncPlugin for LuaPlugin {}
550
551fn hook_name_to_enum(name: &str) -> Option<PluginHook> {
554 Some(match name {
555 "on_issue_created" => PluginHook::OnIssueCreated,
556 "on_issue_updated" => PluginHook::OnIssueUpdated,
557 "on_issue_deleted" => PluginHook::OnIssueDeleted,
558 "on_status_changed" => PluginHook::OnStatusChanged,
559 "on_sync_push" => PluginHook::OnSyncPush,
560 "on_sync_pull" => PluginHook::OnSyncPull,
561 "on_merge_request_created" => PluginHook::OnMergeRequestCreated,
562 "on_external_sync" => PluginHook::OnExternalSync,
563 "on_webhook_received" => PluginHook::OnWebhookReceived,
564 "on_due_date_approaching" => PluginHook::OnDueDateApproaching,
565 "on_due_date_passed" => PluginHook::OnDueDatePassed,
566 "on_report_requested" => PluginHook::OnReportRequested,
567 "on_metric_query" => PluginHook::OnMetricQuery,
568 "on_command" => PluginHook::OnCommand(String::new()), _ => return None,
570 })
571}
572
573fn hook_enum_to_name(hook: &PluginHook) -> Option<&'static str> {
574 Some(match hook {
575 PluginHook::OnIssueCreated => "on_issue_created",
576 PluginHook::OnIssueUpdated => "on_issue_updated",
577 PluginHook::OnIssueDeleted => "on_issue_deleted",
578 PluginHook::OnStatusChanged => "on_status_changed",
579 PluginHook::OnSyncPush => "on_sync_push",
580 PluginHook::OnSyncPull => "on_sync_pull",
581 PluginHook::OnMergeRequestCreated => "on_merge_request_created",
582 PluginHook::OnExternalSync => "on_external_sync",
583 PluginHook::OnWebhookReceived => "on_webhook_received",
584 PluginHook::OnDueDateApproaching => "on_due_date_approaching",
585 PluginHook::OnDueDatePassed => "on_due_date_passed",
586 PluginHook::OnReportRequested => "on_report_requested",
587 PluginHook::OnMetricQuery => "on_metric_query",
588 PluginHook::OnCommand(_) => "on_command", _ => return None,
590 })
591}
592
593fn install_instruction_hook(lua: &Lua, max: u64) -> Result<()> {
599 use std::sync::atomic::{AtomicU64, Ordering};
600 let counter = Arc::new(AtomicU64::new(0));
601 let triggers = HookTriggers::new().every_nth_instruction(4096);
602 let counter_cl = counter.clone();
603 lua.set_hook(triggers, move |_, _debug| {
604 let n = counter_cl.fetch_add(4096, Ordering::Relaxed);
605 if n >= max {
606 return Err(mlua::Error::RuntimeError(format!(
607 "plugin exceeded instruction budget ({max})"
608 )));
609 }
610 Ok(mlua::VmState::Continue)
611 })
612 .map_err(|e| anyhow::anyhow!("Failed to install Lua instruction hook: {e}"))?;
613 Ok(())
614}
615
616fn setup_safe_stdlib(
618 lua: &Lua,
619 opts: &LuaPluginOptions,
620 storage: Arc<Mutex<Option<JsonFileStorage>>>,
621) -> mlua::Result<()> {
622 let globals = lua.globals();
623
624 let http_cap = Arc::new(HttpCap::new(opts)?);
626 let http = lua.create_table()?;
627
628 let cap = http_cap.clone();
629 let http_get = lua.create_function(move |lua, (url, headers): (String, Option<Table>)| {
630 cap.check(&url)?;
631 let mut req = cap.client.get(&url);
632 if let Some(h) = headers {
633 for pair in h.pairs::<String, String>() {
634 let (k, v) = pair?;
635 req = req.header(k, v);
636 }
637 }
638 send_and_wrap(lua, req)
639 })?;
640
641 let cap = http_cap.clone();
642 let http_post = lua.create_function(
643 move |lua, (url, body, headers): (String, String, Option<Table>)| {
644 cap.check(&url)?;
645 let mut req = cap.client.post(&url).body(body);
646 if let Some(h) = headers {
647 for pair in h.pairs::<String, String>() {
648 let (k, v) = pair?;
649 req = req.header(k, v);
650 }
651 }
652 send_and_wrap(lua, req)
653 },
654 )?;
655
656 let cap = http_cap.clone();
657 let http_put = lua.create_function(
658 move |lua, (url, body, headers): (String, String, Option<Table>)| {
659 cap.check(&url)?;
660 let mut req = cap.client.put(&url).body(body);
661 if let Some(h) = headers {
662 for pair in h.pairs::<String, String>() {
663 let (k, v) = pair?;
664 req = req.header(k, v);
665 }
666 }
667 send_and_wrap(lua, req)
668 },
669 )?;
670
671 let cap = http_cap.clone();
672 let http_delete =
673 lua.create_function(move |lua, (url, headers): (String, Option<Table>)| {
674 cap.check(&url)?;
675 let mut req = cap.client.delete(&url);
676 if let Some(h) = headers {
677 for pair in h.pairs::<String, String>() {
678 let (k, v) = pair?;
679 req = req.header(k, v);
680 }
681 }
682 send_and_wrap(lua, req)
683 })?;
684
685 http.set("get", http_get)?;
686 http.set("post", http_post)?;
687 http.set("put", http_put)?;
688 http.set("delete", http_delete)?;
689 globals.set("http", http)?;
690
691 let json = lua.create_table()?;
693 let json_encode = lua.create_function(|lua, value: Value| {
694 let json_val: serde_json::Value = lua
695 .from_value(value)
696 .map_err(|e| mlua::Error::RuntimeError(format!("json.encode: {e}")))?;
697 serde_json::to_string(&json_val)
698 .map_err(|e| mlua::Error::RuntimeError(format!("json.encode: {e}")))
699 })?;
700 let json_decode = lua.create_function(|lua, s: String| {
701 let json_val: serde_json::Value = serde_json::from_str(&s)
702 .map_err(|e| mlua::Error::RuntimeError(format!("json.decode: {e}")))?;
703 lua.to_value(&json_val)
704 .map_err(|e| mlua::Error::RuntimeError(format!("json.decode: {e}")))
705 })?;
706 json.set("encode", json_encode)?;
707 json.set("decode", json_decode)?;
708 globals.set("json", json)?;
709
710 let log = lua.create_table()?;
712 let log_debug = lua.create_function(|_, msg: String| {
713 log::debug!(target: "progit_plugin", "{msg}");
714 Ok(())
715 })?;
716 let log_info = lua.create_function(|_, msg: String| {
717 log::info!(target: "progit_plugin", "{msg}");
718 Ok(())
719 })?;
720 let log_warn = lua.create_function(|_, msg: String| {
721 log::warn!(target: "progit_plugin", "{msg}");
722 Ok(())
723 })?;
724 let log_error = lua.create_function(|_, msg: String| {
725 log::error!(target: "progit_plugin", "{msg}");
726 Ok(())
727 })?;
728 log.set("debug", log_debug)?;
729 log.set("info", log_info)?;
730 log.set("warn", log_warn)?;
731 log.set("error", log_error)?;
732 globals.set("log", log)?;
733
734 let log_info_global = lua.create_function(|_, msg: String| {
736 log::info!(target: "progit_plugin", "{msg}");
737 Ok(())
738 })?;
739 let log_warn_global = lua.create_function(|_, msg: String| {
740 log::warn!(target: "progit_plugin", "{msg}");
741 Ok(())
742 })?;
743 let log_error_global = lua.create_function(|_, msg: String| {
744 log::error!(target: "progit_plugin", "{msg}");
745 Ok(())
746 })?;
747 globals.set("log_info", log_info_global)?;
748 globals.set("log_warn", log_warn_global)?;
749 globals.set("log_error", log_error_global)?;
750
751 let sober_tbl = lua.create_table()?;
753 if let Some(host) = opts.sober.clone() {
754 let sober_run = lua.create_function(
755 move |lua, (action, options): (String, Option<Value>)| {
756 let options_json = match options {
757 Some(value) => lua.from_value(value).map_err(|e| {
758 mlua::Error::RuntimeError(format!("sober.run: options: {e}"))
759 })?,
760 None => serde_json::Value::Object(Default::default()),
761 };
762 let result = host.run(SoberInvocation {
763 action,
764 options: options_json,
765 });
766 lua.to_value(&result)
767 .map_err(|e| mlua::Error::RuntimeError(format!("sober.run: result: {e}")))
768 },
769 )?;
770 sober_tbl.set("run", sober_run)?;
771 } else {
772 let sober_run = lua.create_function(|_, (_action, _options): (String, Option<Value>)| {
773 Err::<Value, _>(mlua::Error::RuntimeError(
774 "sober.run requires the manifest capability `sober = true` and a host bridge"
775 .into(),
776 ))
777 })?;
778 sober_tbl.set("run", sober_run)?;
779 }
780 globals.set("sober", sober_tbl)?;
781
782 let storage_tbl = lua.create_table()?;
784
785 let s = storage.clone();
786 let storage_get = lua.create_function(move |lua, key: String| {
787 let guard = s
788 .lock()
789 .map_err(|_| mlua::Error::RuntimeError("storage poisoned".into()))?;
790 let val = match guard.as_ref() {
791 Some(st) => st
792 .get(&key)
793 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?,
794 None => return Ok(Value::Nil),
795 };
796 match val {
797 Some(v) => lua
798 .to_value(&v)
799 .map_err(|e| mlua::Error::RuntimeError(e.to_string())),
800 None => Ok(Value::Nil),
801 }
802 })?;
803
804 let s = storage.clone();
805 let storage_set = lua.create_function(move |lua, (key, value): (String, Value)| {
806 let json_val: serde_json::Value = lua
807 .from_value(value)
808 .map_err(|e| mlua::Error::RuntimeError(format!("storage.set: {e}")))?;
809 let mut guard = s
810 .lock()
811 .map_err(|_| mlua::Error::RuntimeError("storage poisoned".into()))?;
812 match guard.as_mut() {
813 Some(st) => st
814 .set(&key, &json_val)
815 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?,
816 None => return Err(mlua::Error::RuntimeError("storage not initialised".into())),
817 }
818 Ok(())
819 })?;
820
821 let s = storage.clone();
822 let storage_delete = lua.create_function(move |_, key: String| {
823 let mut guard = s
824 .lock()
825 .map_err(|_| mlua::Error::RuntimeError("storage poisoned".into()))?;
826 let removed = match guard.as_mut() {
827 Some(st) => st
828 .delete(&key)
829 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?,
830 None => false,
831 };
832 Ok(removed)
833 })?;
834
835 let s = storage.clone();
836 let storage_keys = lua.create_function(move |lua, ()| {
837 let guard = s
838 .lock()
839 .map_err(|_| mlua::Error::RuntimeError("storage poisoned".into()))?;
840 let keys: Vec<String> = match guard.as_ref() {
841 Some(st) => st
842 .keys()
843 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?,
844 None => Vec::new(),
845 };
846 let t = lua.create_table()?;
847 for (i, k) in keys.into_iter().enumerate() {
848 t.set(i + 1, k)?;
849 }
850 Ok(t)
851 })?;
852
853 let storage_clear = {
854 let s = storage.clone();
855 lua.create_function(move |_, ()| {
856 let mut guard = s
857 .lock()
858 .map_err(|_| mlua::Error::RuntimeError("storage poisoned".into()))?;
859 if let Some(st) = guard.as_mut() {
860 st.clear()
861 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
862 }
863 Ok(())
864 })?
865 };
866
867 storage_tbl.set("get", storage_get)?;
868 storage_tbl.set("set", storage_set)?;
869 storage_tbl.set("delete", storage_delete)?;
870 storage_tbl.set("keys", storage_keys)?;
871 storage_tbl.set("clear", storage_clear)?;
872 globals.set("storage", storage_tbl)?;
873
874 let package: Table = globals.get("package")?;
876 let loaded: Table = package.get("loaded")?;
877 for name in ["http", "json", "log", "sober", "storage"] {
878 let v: Value = globals.get(name)?;
879 loaded.set(name, v)?;
880 }
881
882 let repo_root = opts.repo_root.clone();
884 let io = lua.create_table()?;
885
886 let repo_for_open = repo_root.clone();
887 let io_open = lua.create_function(move |lua, (path, mode): (String, Option<String>)| {
888 let repo_str = repo_for_open.as_ref()
889 .map(|s| s.to_string_lossy().to_string())
890 .unwrap_or_else(|| ".".to_string());
891
892 let m = mode.unwrap_or_else(|| "r".to_string());
893 if !m.starts_with('r') && !m.starts_with('a') {
896 return Err(mlua::Error::RuntimeError(
897 "io: only read ('r') and append ('a') modes are allowed".into()
898 ));
899 }
900
901 let abs = if path.starts_with('/') { path.clone() }
902 else { format!("{}/{}", repo_str, path) };
903
904 let content = match std::fs::read_to_string(&abs) {
906 Ok(c) => c,
907 Err(e) => return Err(mlua::Error::RuntimeError(format!("io: {}", e))),
908 };
909
910 let file = lua.create_table()?;
912 let c = content.clone();
913 let read_fn = lua.create_function(move |_, fmt: String| {
914 if fmt == "*a" { Ok(c.clone()) }
915 else if fmt == "*l" { Ok(c.lines().next().unwrap_or_default().to_string()) }
916 else { Ok(c.clone()) }
917 })?;
918 file.set("read", read_fn)?;
919 let close_fn = lua.create_function(|_, ()| Ok(()))?;
920 file.set("close", close_fn)?;
921 let lines_vec: Vec<String> = content.lines().map(|s| s.to_string()).collect();
922 let lines_fn = lua.create_function(move |lua, ()| {
923 let lines = lines_vec.clone();
924 let idx = std::cell::RefCell::new(0usize);
925 Ok(lua.create_function(move |_, ()| {
926 *idx.borrow_mut() += 1;
927 let i = *idx.borrow();
928 if i <= lines.len() { Ok(Some(lines[i-1].clone())) }
929 else { Ok(None) }
930 }))
931 })?;
932 file.set("lines", lines_fn)?;
933 Ok(file)
934 })?;
935
936 io.set("open", io_open.clone())?;
937 globals.set("io", io)?;
938 globals.set("io_open", io_open)?;
939
940 Ok(())
941}
942
943fn send_and_wrap(lua: &Lua, req: reqwest::blocking::RequestBuilder) -> mlua::Result<Table> {
944 let resp = req
945 .send()
946 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
947 let status = resp.status().as_u16() as i64;
948 let ok = resp.status().is_success();
949 let body = resp
950 .text()
951 .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
952 let t = lua.create_table()?;
953 t.set("status", status)?;
954 t.set("body", body)?;
955 t.set("ok", ok)?;
956 Ok(t)
957}
958
959fn neutralise_dangerous(lua: &Lua, opts: &LuaPluginOptions) -> mlua::Result<()> {
962 let globals = lua.globals();
963
964 if let Ok(os_tbl) = globals.get::<Table>("os") {
965 for name in ["execute", "exit", "remove", "rename", "tmpname", "setlocale"] {
966 let banned = name.to_string();
967 let f = lua.create_function(move |_, ()| {
968 Err::<(), _>(mlua::Error::RuntimeError(format!(
969 "os.{banned} is disabled in the ProGit plugin sandbox"
970 )))
971 })?;
972 os_tbl.set(name, f)?;
973 }
974 if !opts.env_access {
975 let f = lua.create_function(|_, _name: String| {
976 Err::<Option<String>, _>(mlua::Error::RuntimeError(
977 "os.getenv is disabled (capability 'env' not granted)".into(),
978 ))
979 })?;
980 os_tbl.set("getenv", f)?;
981 }
982 }
983
984 if let Ok(pkg_tbl) = globals.get::<Table>("package") {
985 for name in ["loadlib", "searchpath"] {
986 let banned = name.to_string();
987 let f = lua.create_function(move |_, ()| {
988 Err::<(), _>(mlua::Error::RuntimeError(format!(
989 "package.{banned} is disabled in the sandbox"
990 )))
991 })?;
992 pkg_tbl.set(name, f)?;
993 }
994 }
995
996 Ok(())
999}
1000
1001#[cfg(test)]
1002mod tests {
1003 use super::*;
1004
1005 fn loose_opts() -> LuaPluginOptions {
1006 let mut opts = LuaPluginOptions::default();
1007 opts.network = false;
1008 opts.max_instructions = 0;
1009 opts
1010 }
1011
1012 #[test]
1013 fn loads_minimal_plugin() {
1014 let script = r#"
1015 plugin = {
1016 name = "test", version = "1.0.0", author = "t",
1017 hooks = { on_issue_created = true }
1018 }
1019 function on_issue_created(issue)
1020 return { ok = true }
1021 end
1022 "#;
1023 let p = LuaPlugin::from_string_with_options(script, "test", loose_opts()).unwrap();
1024 assert_eq!(p.metadata().name, "test");
1025 }
1026
1027 #[test]
1028 fn os_execute_is_blocked() {
1029 let script = r#"
1030 plugin = { name="x", version="1", author="y", hooks = {} }
1031 os.execute("ls")
1032 "#;
1033 let err = LuaPlugin::from_string_with_options(script, "x", loose_opts())
1034 .err()
1035 .expect("os.execute should be blocked");
1036 let msg = format!("{err}");
1037 assert!(msg.contains("disabled"), "got: {msg}");
1038 }
1039
1040 #[test]
1041 fn io_module_shim_works() {
1042 let script = r#"
1044 plugin = { name="x", version="1", author="y", hooks = {} }
1045 assert(type(io) == "table", "io should be a table")
1046 assert(type(io.open) == "function", "io.open should be a function")
1047 "#;
1048 LuaPlugin::from_string_with_options(script, "x", loose_opts()).unwrap();
1049 }
1050
1051 #[test]
1052 fn debug_module_is_absent() {
1053 let script = r#"
1054 plugin = { name="x", version="1", author="y", hooks = {} }
1055 assert(debug == nil, "debug should be nil")
1056 "#;
1057 LuaPlugin::from_string_with_options(script, "x", loose_opts()).unwrap();
1058 }
1059
1060 #[test]
1061 fn http_blocked_without_network_capability() {
1062 let script = r#"
1063 plugin = { name="x", version="1", author="y", hooks = { on_issue_created = true } }
1064 function on_issue_created(_)
1065 local r = http.get("https://example.com")
1066 return { ok = r.ok }
1067 end
1068 "#;
1069 let opts = loose_opts();
1070 let mut p = LuaPlugin::from_string_with_options(script, "x", opts).unwrap();
1071 let ctx = PluginContext {
1072 repo_path: std::env::temp_dir().to_string_lossy().to_string(),
1073 user: None,
1074 env: Default::default(),
1075 config: Default::default(),
1076 };
1077 p.init(&ctx).unwrap();
1078 let err = p
1079 .execute_hook(&PluginHook::OnIssueCreated, &serde_json::json!({}))
1080 .err()
1081 .expect("network should be denied");
1082 assert!(format!("{err}").contains("network"));
1083 }
1084
1085 #[test]
1086 fn storage_round_trip() {
1087 let script = r#"
1088 plugin = { name="store-test", version="1", author="t",
1089 hooks = { on_issue_created = true } }
1090 function on_issue_created(_)
1091 storage.set("k", { v = 42 })
1092 local got = storage.get("k")
1093 return { v = got.v }
1094 end
1095 "#;
1096 let temp = tempfile::tempdir().unwrap();
1097 let mut opts = loose_opts();
1098 opts.repo_root = Some(temp.path().to_path_buf());
1099 let mut p = LuaPlugin::from_string_with_options(script, "store-test", opts).unwrap();
1100 let ctx = PluginContext {
1101 repo_path: temp.path().to_string_lossy().to_string(),
1102 user: None,
1103 env: Default::default(),
1104 config: Default::default(),
1105 };
1106 p.init(&ctx).unwrap();
1107 let r = p
1108 .execute_hook(&PluginHook::OnIssueCreated, &serde_json::json!({}))
1109 .unwrap();
1110 assert_eq!(r["v"], 42);
1111 }
1112
1113 #[test]
1114 fn sober_capability_is_blocked_without_host() {
1115 let script = r#"
1116 plugin = { name="sober-denied", version="1", author="t",
1117 hooks = { on_issue_created = true } }
1118 function on_issue_created(_)
1119 return sober.run("doctor", {})
1120 end
1121 "#;
1122 let mut p = LuaPlugin::from_string_with_options(script, "sober-denied", loose_opts()).unwrap();
1123 let ctx = PluginContext {
1124 repo_path: std::env::temp_dir().to_string_lossy().to_string(),
1125 user: None,
1126 env: Default::default(),
1127 config: Default::default(),
1128 };
1129 p.init(&ctx).unwrap();
1130 let err = p
1131 .execute_hook(&PluginHook::OnIssueCreated, &serde_json::json!({}))
1132 .err()
1133 .expect("sober should require host capability");
1134 assert!(format!("{err}").contains("sober.run requires"));
1135 }
1136
1137 #[test]
1138 fn sober_capability_round_trips_through_host() {
1139 let script = r#"
1140 plugin = { name="sober-ok", version="1", author="t",
1141 hooks = { on_issue_created = true } }
1142 function on_issue_created(_)
1143 return sober.run("preflight", { base = "HEAD" })
1144 end
1145 "#;
1146 let mut opts = loose_opts();
1147 opts.sober = Some(SoberHost::new(|invocation| {
1148 assert_eq!(invocation.action, "preflight");
1149 assert_eq!(invocation.options["base"], "HEAD");
1150 SoberInvocationResult {
1151 ok: true,
1152 data: serde_json::json!({ "action": invocation.action, "ok": true }),
1153 error: None,
1154 }
1155 }));
1156 let mut p = LuaPlugin::from_string_with_options(script, "sober-ok", opts).unwrap();
1157 let ctx = PluginContext {
1158 repo_path: std::env::temp_dir().to_string_lossy().to_string(),
1159 user: None,
1160 env: Default::default(),
1161 config: Default::default(),
1162 };
1163 p.init(&ctx).unwrap();
1164 let r = p
1165 .execute_hook(&PluginHook::OnIssueCreated, &serde_json::json!({}))
1166 .unwrap();
1167 assert_eq!(r["ok"], true);
1168 assert_eq!(r["data"]["action"], "preflight");
1169 }
1170
1171 #[test]
1172 fn instruction_cap_trips_runaway_loop() {
1173 let script = r#"
1182 jit.off()
1183 plugin = { name="loopy", version="1", author="t",
1184 hooks = { on_issue_created = true } }
1185 function on_issue_created(_)
1186 local i = 0
1187 while true do i = i + 1 end
1188 return { i = i }
1189 end
1190 "#;
1191 let mut opts = loose_opts();
1192 opts.max_instructions = 100_000;
1193 let mut p = LuaPlugin::from_string_with_options(script, "loopy", opts).unwrap();
1194 let ctx = PluginContext {
1195 repo_path: std::env::temp_dir().to_string_lossy().to_string(),
1196 user: None,
1197 env: Default::default(),
1198 config: Default::default(),
1199 };
1200 p.init(&ctx).unwrap();
1201 let err = p
1202 .execute_hook(&PluginHook::OnIssueCreated, &serde_json::json!({}))
1203 .err()
1204 .expect("runaway loop should hit the instruction cap");
1205 assert!(format!("{err}").contains("instruction budget"), "{err}");
1206 }
1207
1208 #[test]
1209 fn highlight_round_trips_through_lua() {
1210 let script = r#"
1211 plugin = { name="hl", version="1", author="t", hooks = {} }
1212 function plugin.highlight(req)
1213 return {
1214 spans = {
1215 { text = "fn ", fg = { r = 200, g = 0, b = 0 }, bold = true },
1216 { text = req.content:sub(4) },
1217 }
1218 }
1219 end
1220 "#;
1221 let mut p = LuaPlugin::from_string_with_options(script, "hl", loose_opts()).unwrap();
1222 let ctx = PluginContext {
1223 repo_path: std::env::temp_dir().to_string_lossy().to_string(),
1224 user: None,
1225 env: Default::default(),
1226 config: Default::default(),
1227 };
1228 p.init(&ctx).unwrap();
1229 let resp = p
1230 .highlight(&HighlightRequest {
1231 language: Some("rust".into()),
1232 content: "fn main() {}".into(),
1233 })
1234 .expect("highlight should not error")
1235 .expect("plugin should return spans");
1236 assert_eq!(resp.spans.len(), 2);
1237 assert_eq!(resp.spans[0].text, "fn ");
1238 assert_eq!(resp.spans[0].fg, Some(crate::render::Rgb::new(200, 0, 0)));
1239 assert!(resp.spans[0].bold);
1240 assert_eq!(resp.spans[1].text, "main() {}");
1241 }
1242
1243 #[test]
1244 fn highlight_returns_none_when_plugin_does_not_implement_it() {
1245 let script = r#"
1246 plugin = { name="nohl", version="1", author="t", hooks = {} }
1247 "#;
1248 let mut p = LuaPlugin::from_string_with_options(script, "nohl", loose_opts()).unwrap();
1249 let ctx = PluginContext {
1250 repo_path: std::env::temp_dir().to_string_lossy().to_string(),
1251 user: None,
1252 env: Default::default(),
1253 config: Default::default(),
1254 };
1255 p.init(&ctx).unwrap();
1256 let resp = p
1257 .highlight(&HighlightRequest {
1258 language: None,
1259 content: "x".into(),
1260 })
1261 .unwrap();
1262 assert!(resp.is_none(), "plugin without highlight() should yield None");
1263 }
1264
1265 #[test]
1266 fn back_compat_global_log_info_works() {
1267 let script = r#"
1268 plugin = { name="bc", version="1", author="t",
1269 hooks = { on_issue_created = true } }
1270 function on_issue_created(_)
1271 log_info("hello from v0.1 style")
1272 return { ok = true }
1273 end
1274 "#;
1275 let mut p = LuaPlugin::from_string_with_options(script, "bc", loose_opts()).unwrap();
1276 let ctx = PluginContext {
1277 repo_path: std::env::temp_dir().to_string_lossy().to_string(),
1278 user: None,
1279 env: Default::default(),
1280 config: Default::default(),
1281 };
1282 p.init(&ctx).unwrap();
1283 let r = p
1284 .execute_hook(&PluginHook::OnIssueCreated, &serde_json::json!({}))
1285 .unwrap();
1286 assert_eq!(r["ok"], true);
1287 }
1288}