use crate::manifest::DiscoveredPlugin;
use crate::protocol::PluginOutput;
use std::io::Read;
use std::path::{Component, Path};
use std::process::{Child, Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
use thiserror::Error;
const DEFAULT_TIMEOUT_SECS: u64 = 60;
const MAX_STDIO_BYTES: usize = 1024 * 1024;
#[derive(Debug, Error)]
pub enum SubprocessError {
#[error("plugin binary not found: {0}")]
NotFound(String),
#[error("plugin binary path is not allowed: {0}")]
PathNotAllowed(String),
#[error("plugin subprocess failed: {0}")]
RunFailed(String),
#[error("plugin subprocess timed out after {0}s")]
TimedOut(u64),
#[error("plugin subprocess output exceeded {0} bytes")]
OutputTooLarge(usize),
#[error("plugin output is not valid JSON: {0}")]
InvalidOutput(String),
}
pub struct SubprocessRequest<'a> {
pub action: &'a str,
pub workspace: &'a Path,
pub step: Option<&'a str>,
pub extra_args: &'a [String],
}
pub fn resolve_entry_path(
plugin: &DiscoveredPlugin,
) -> Result<std::path::PathBuf, SubprocessError> {
let entry = plugin
.manifest
.entry
.as_deref()
.ok_or_else(|| SubprocessError::NotFound("missing entry".to_string()))?;
let path = if Path::new(entry).is_absolute() {
Path::new(entry).to_path_buf()
} else {
plugin.manifest_path.parent().unwrap_or(Path::new(".")).join(entry)
};
validate_binary_path(&path)?;
if let Some(root) = infer_workspace_root(&plugin.manifest_path) {
let root = root.canonicalize().unwrap_or(root);
let resolved = path.canonicalize().unwrap_or(path.clone());
if !ontocore_core::is_path_within(&root, &resolved) {
return Err(SubprocessError::PathNotAllowed(path.display().to_string()));
}
}
if !path.exists() {
return Err(SubprocessError::NotFound(path.display().to_string()));
}
Ok(path)
}
fn infer_workspace_root(manifest_path: &Path) -> Option<std::path::PathBuf> {
let plugins_dir = manifest_path.parent()?;
let ontocore_dir = plugins_dir.parent()?;
if ontocore_dir.file_name().and_then(|n| n.to_str()) != Some(".ontocore") {
return None;
}
ontocore_dir.parent().map(|p| p.to_path_buf())
}
fn validate_binary_path(path: &Path) -> Result<(), SubprocessError> {
if path.components().any(|c| matches!(c, Component::ParentDir)) {
return Err(SubprocessError::PathNotAllowed(path.display().to_string()));
}
Ok(())
}
fn read_capped(mut reader: impl Read, cap: usize) -> Result<Vec<u8>, SubprocessError> {
let mut buf = Vec::new();
let mut chunk = [0u8; 8192];
loop {
let n = reader.read(&mut chunk).map_err(|e| SubprocessError::RunFailed(e.to_string()))?;
if n == 0 {
break;
}
if buf.len().saturating_add(n) > cap {
return Err(SubprocessError::OutputTooLarge(cap));
}
buf.extend_from_slice(&chunk[..n]);
}
Ok(buf)
}
fn configure_process_group(cmd: &mut Command) {
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
}
fn kill_plugin_tree(child: &mut Child) {
#[cfg(unix)]
{
let pgid = child.id() as i32;
let group_kill = unsafe { libc::kill(-pgid, libc::SIGKILL) };
if group_kill != 0 {
let _ = child.kill();
}
}
#[cfg(not(unix))]
{
let _ = child.kill();
}
}
pub fn run_plugin_subprocess(
plugin: &DiscoveredPlugin,
request: SubprocessRequest<'_>,
) -> Result<PluginOutput, SubprocessError> {
run_plugin_subprocess_with_timeout(plugin, request, Duration::from_secs(DEFAULT_TIMEOUT_SECS))
}
pub(crate) fn run_plugin_subprocess_with_timeout(
plugin: &DiscoveredPlugin,
request: SubprocessRequest<'_>,
timeout: Duration,
) -> Result<PluginOutput, SubprocessError> {
let binary = resolve_entry_path(plugin)?;
let mut cmd = Command::new(&binary);
cmd.arg(request.action)
.arg("--workspace")
.arg(request.workspace)
.env("ONTOCORE_PLUGIN_ACTION", request.action)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(step) = request.step {
cmd.arg("--step").arg(step);
}
for arg in request.extra_args {
cmd.arg(arg);
}
configure_process_group(&mut cmd);
let mut child = cmd.spawn().map_err(|e| SubprocessError::RunFailed(e.to_string()))?;
let mut stdout = child.stdout.take().expect("stdout piped");
let mut stderr = child.stderr.take().expect("stderr piped");
let out_handle = thread::spawn(move || read_capped(&mut stdout, MAX_STDIO_BYTES));
let err_handle = thread::spawn(move || read_capped(&mut stderr, MAX_STDIO_BYTES));
let deadline = Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) => {
let out = out_handle.join().map_err(|_| {
SubprocessError::RunFailed("stdout reader panicked".to_string())
})??;
let err = err_handle.join().map_err(|_| {
SubprocessError::RunFailed("stderr reader panicked".to_string())
})??;
if !status.success() {
let stderr = String::from_utf8_lossy(&err);
return Err(SubprocessError::RunFailed(format!(
"exit {}: {}",
status.code().unwrap_or(-1),
stderr.trim()
)));
}
let stdout = String::from_utf8_lossy(&out);
if stdout.trim().is_empty() {
return Ok(PluginOutput::default());
}
return serde_json::from_str(stdout.trim()).map_err(|e| {
SubprocessError::InvalidOutput(format!("{e}: {}", stdout.trim()))
});
}
Ok(None) => {
if Instant::now() >= deadline {
kill_plugin_tree(&mut child);
let _ = child.wait();
let _ = out_handle.join();
let _ = err_handle.join();
return Err(SubprocessError::TimedOut(timeout.as_secs().max(1)));
}
thread::sleep(Duration::from_millis(25));
}
Err(e) => {
kill_plugin_tree(&mut child);
let _ = child.wait();
let _ = out_handle.join();
let _ = err_handle.join();
return Err(SubprocessError::RunFailed(e.to_string()));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::{parse_manifest, DiscoveredPlugin};
use std::io::Cursor;
#[test]
fn read_capped_rejects_oversize() {
let data = vec![b'a'; 33];
let err = read_capped(Cursor::new(data), 32).unwrap_err();
matches!(err, SubprocessError::OutputTooLarge(32));
}
#[cfg(unix)]
#[test]
fn timeout_kills_process_group_grandchildren() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let workspace = dir.path().join("ws");
std::fs::create_dir_all(workspace.join(".ontocore/plugins")).expect("plugins dir");
let marker = workspace.join("grandchild.pid");
let plugin_bin = workspace.join("slow_plugin.sh");
let script = format!(
r#"#!/bin/sh
sleep 999 &
echo $! > "{}"
wait
"#,
marker.display()
);
std::fs::write(&plugin_bin, script).expect("write plugin");
let mut perms = std::fs::metadata(&plugin_bin).expect("metadata").permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&plugin_bin, perms).expect("chmod");
let manifest_path = workspace.join(".ontocore/plugins/slow.toml");
let manifest_toml = format!(
r#"[plugin]
name = "slow"
version = "0.1.0"
kind = "validator"
id = "org.example.slow"
api_version = "1"
entry = "{}"
permissions = ["workspace.read","workspace.write","external_process"]
[capabilities]
validate = true
diagnostics = true
"#,
plugin_bin.display()
);
std::fs::write(&manifest_path, &manifest_toml).expect("write manifest");
let plugin = DiscoveredPlugin {
manifest: parse_manifest(&manifest_toml).expect("parse"),
manifest_path,
};
let err = run_plugin_subprocess_with_timeout(
&plugin,
SubprocessRequest {
action: "validate",
workspace: &workspace,
step: None,
extra_args: &[],
},
Duration::from_millis(200),
)
.expect_err("must time out");
assert!(matches!(err, SubprocessError::TimedOut(_)), "got {err:?}");
thread::sleep(Duration::from_millis(150));
let pid_text = std::fs::read_to_string(&marker).expect("grandchild pid written");
let pid: i32 = pid_text.trim().parse().expect("pid");
let still_alive = unsafe { libc::kill(pid, 0) == 0 };
assert!(!still_alive, "grandchild pid {pid} should be dead after process-group kill");
}
}