use anyhow::{bail, Context, Result};
use console::style;
use indicatif::{ProgressBar, ProgressStyle};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::Duration;
#[derive(Debug, Serialize)]
pub struct PluginContext {
#[serde(rename = "type")]
msg_type: &'static str,
protocol: &'static str,
args: Vec<String>,
project: Option<ProjectContext>,
plugin: PluginInfo,
fledge: FledgeInfo,
capabilities: CapabilitiesInfo,
}
#[derive(Debug, Serialize)]
struct CapabilitiesInfo {
exec: bool,
store: bool,
metadata: bool,
}
#[derive(Debug, Serialize)]
struct ProjectContext {
name: String,
root: String,
language: String,
git: Option<GitContext>,
}
#[derive(Debug, Serialize)]
struct GitContext {
branch: String,
dirty: bool,
remote: String,
remote_url: String,
}
#[derive(Debug, Serialize)]
struct PluginInfo {
name: String,
version: String,
dir: String,
}
#[derive(Debug, Serialize)]
struct FledgeInfo {
version: String,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
#[serde(rename_all = "snake_case")]
pub enum OutboundMessage {
Prompt {
id: String,
message: String,
#[serde(default)]
default: Option<String>,
#[serde(default)]
validate: Option<String>,
},
Confirm {
id: String,
message: String,
#[serde(default)]
default: Option<bool>,
},
Select {
id: String,
message: String,
options: Vec<String>,
#[serde(default)]
default: Option<usize>,
},
MultiSelect {
id: String,
message: String,
options: Vec<String>,
#[serde(default)]
defaults: Option<Vec<usize>>,
},
Progress {
#[serde(default)]
message: Option<String>,
#[serde(default)]
current: Option<u64>,
#[serde(default)]
total: Option<u64>,
#[serde(default)]
done: Option<bool>,
},
Log {
level: String,
message: String,
},
Output {
text: String,
},
Store {
key: String,
value: String,
},
Load {
id: String,
key: String,
},
Exec {
id: String,
command: String,
#[serde(default)]
cwd: Option<String>,
#[serde(default)]
timeout: Option<u64>,
},
Metadata {
id: String,
keys: Vec<String>,
},
}
#[derive(Debug, Serialize)]
struct InboundResponse {
#[serde(rename = "type")]
msg_type: &'static str,
id: String,
value: serde_json::Value,
}
pub fn run_protocol_plugin(
bin_path: &Path,
args: &[String],
plugin_name: &str,
plugin_version: &str,
plugin_dir: &Path,
capabilities: &crate::plugin::PluginCapabilities,
) -> Result<()> {
let mut child = Command::new(bin_path)
.env("FLEDGE_PLUGIN_DIR", plugin_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.with_context(|| format!("spawning plugin '{plugin_name}'"))?;
let project_ctx = detect_project_context();
let init_msg = PluginContext {
msg_type: "init",
protocol: "fledge-v1",
args: args.to_vec(),
project: project_ctx,
plugin: PluginInfo {
name: plugin_name.to_string(),
version: plugin_version.to_string(),
dir: plugin_dir.to_string_lossy().to_string(),
},
fledge: FledgeInfo {
version: env!("CARGO_PKG_VERSION").to_string(),
},
capabilities: CapabilitiesInfo {
exec: capabilities.exec,
store: capabilities.store,
metadata: capabilities.metadata,
},
};
send_message(&mut child, &init_msg)?;
let result = run_message_loop(&mut child, plugin_name, plugin_dir, capabilities);
let status = child.wait().context("waiting for plugin to exit")?;
result?;
if !status.success() {
let code = status.code().unwrap_or(1);
bail!("Plugin '{}' exited with code {}", plugin_name, code);
}
Ok(())
}
fn run_message_loop(
child: &mut Child,
plugin_name: &str,
plugin_dir: &Path,
capabilities: &crate::plugin::PluginCapabilities,
) -> Result<()> {
let stdout = child
.stdout
.take()
.context("failed to capture plugin stdout")?;
let reader = BufReader::new(stdout);
let mut progress_bar: Option<ProgressBar> = None;
for line in reader.lines() {
let line = line.context("reading plugin output")?;
if line.trim().is_empty() {
continue;
}
let msg: OutboundMessage = match serde_json::from_str(&line) {
Ok(m) => m,
Err(_) => {
eprintln!(
" {} {}: malformed JSON, skipping",
style("⚠").yellow(),
style(plugin_name).dim()
);
continue;
}
};
match msg {
OutboundMessage::Prompt {
id,
message,
default,
validate,
} => {
clear_progress(&mut progress_bar);
let value = handle_prompt(&message, default.as_deref(), validate.as_deref())?;
send_response(child, &id, serde_json::Value::String(value))?;
}
OutboundMessage::Confirm {
id,
message,
default,
} => {
clear_progress(&mut progress_bar);
let value = handle_confirm(&message, default.unwrap_or(false))?;
send_response(child, &id, serde_json::Value::Bool(value))?;
}
OutboundMessage::Select {
id,
message,
options,
default,
} => {
clear_progress(&mut progress_bar);
let value = handle_select(&message, &options, default)?;
send_response(child, &id, serde_json::Value::String(value))?;
}
OutboundMessage::MultiSelect {
id,
message,
options,
defaults,
} => {
clear_progress(&mut progress_bar);
let values = handle_multi_select(&message, &options, defaults.as_deref())?;
let json_values: Vec<serde_json::Value> =
values.into_iter().map(serde_json::Value::String).collect();
send_response(child, &id, serde_json::Value::Array(json_values))?;
}
OutboundMessage::Progress {
message,
current,
total,
done,
} => {
handle_progress(
&mut progress_bar,
plugin_name,
message.as_deref(),
current,
total,
done.unwrap_or(false),
);
}
OutboundMessage::Log { level, message } => {
clear_progress(&mut progress_bar);
handle_log(plugin_name, &level, &message);
}
OutboundMessage::Output { text } => {
clear_progress(&mut progress_bar);
print!("{}", text);
}
OutboundMessage::Store { key, value } => {
if !capabilities.store {
continue;
}
handle_store(plugin_dir, &key, &value)?;
}
OutboundMessage::Load { id, key } => {
if !capabilities.store {
send_response(child, &id, serde_json::Value::Null)?;
continue;
}
let value = handle_load(plugin_dir, &key)?;
send_response(child, &id, value)?;
}
OutboundMessage::Exec {
id,
command,
cwd,
timeout,
} => {
if !capabilities.exec {
eprintln!(
" {} [{}] exec blocked — capability not granted",
style("WARN").yellow(),
style(plugin_name).dim()
);
send_response(
child,
&id,
serde_json::json!({
"code": 126,
"stdout": "",
"stderr": "exec capability not granted"
}),
)?;
continue;
}
let result = handle_exec(&command, cwd.as_deref(), timeout, plugin_dir)?;
send_response(child, &id, result)?;
}
OutboundMessage::Metadata { id, keys } => {
if !capabilities.metadata {
eprintln!(
" {} [{}] metadata blocked — capability not granted",
style("WARN").yellow(),
style(plugin_name).dim()
);
let empty = serde_json::Value::Object(serde_json::Map::new());
send_response(child, &id, empty)?;
continue;
}
let result = handle_metadata(&keys)?;
send_response(child, &id, result)?;
}
}
}
clear_progress(&mut progress_bar);
Ok(())
}
fn send_message<T: Serialize>(child: &mut Child, msg: &T) -> Result<()> {
send_raw(child, msg)
}
fn send_raw<T: Serialize>(child: &mut Child, msg: &T) -> Result<()> {
let stdin = child.stdin.as_mut().context("plugin stdin unavailable")?;
let json = serde_json::to_string(msg).context("serializing message")?;
writeln!(stdin, "{}", json).context("writing to plugin stdin")?;
stdin.flush().context("flushing plugin stdin")?;
Ok(())
}
fn send_response(child: &mut Child, id: &str, value: serde_json::Value) -> Result<()> {
send_raw(
child,
&InboundResponse {
msg_type: "response",
id: id.to_string(),
value,
},
)
}
fn handle_prompt(message: &str, default: Option<&str>, validate: Option<&str>) -> Result<String> {
if !crate::utils::is_interactive() {
if let Some(d) = default {
return Ok(d.to_string());
}
bail!(
"Plugin requested input '{}' but stdin is not a TTY and no default was provided.",
message
);
}
let theme = dialoguer::theme::ColorfulTheme::default();
let mut prompt = dialoguer::Input::<String>::with_theme(&theme).with_prompt(message);
if let Some(d) = default {
prompt = prompt.default(d.to_string());
}
if let Some(v) = validate {
match v {
"non_empty" => {
prompt = prompt.validate_with(|input: &String| -> Result<(), String> {
if input.trim().is_empty() {
Err("Input cannot be empty".to_string())
} else {
Ok(())
}
});
}
"integer" => {
prompt = prompt.validate_with(|input: &String| -> Result<(), String> {
input
.parse::<i64>()
.map(|_| ())
.map_err(|_| "Must be an integer".to_string())
});
}
"path_exists" => {
prompt = prompt.validate_with(|input: &String| -> Result<(), String> {
if Path::new(input).exists() {
Ok(())
} else {
Err("Path does not exist".to_string())
}
});
}
"url" => {
prompt = prompt.validate_with(|input: &String| -> Result<(), String> {
if input.starts_with("http://") || input.starts_with("https://") {
Ok(())
} else {
Err("Must be a valid URL (http:// or https://)".to_string())
}
});
}
_ => {}
}
}
prompt.interact_text().context("reading user input")
}
fn handle_confirm(message: &str, default: bool) -> Result<bool> {
if !crate::utils::is_interactive() {
return Ok(default);
}
let theme = dialoguer::theme::ColorfulTheme::default();
dialoguer::Confirm::with_theme(&theme)
.with_prompt(message)
.default(default)
.interact()
.context("reading confirmation")
}
fn handle_select(message: &str, options: &[String], default: Option<usize>) -> Result<String> {
if !crate::utils::is_interactive() {
let idx = default.unwrap_or(0);
if idx < options.len() {
return Ok(options[idx].clone());
}
bail!(
"Plugin requested selection '{}' but stdin is not a TTY.",
message
);
}
let theme = dialoguer::theme::ColorfulTheme::default();
let mut select = dialoguer::Select::with_theme(&theme)
.with_prompt(message)
.items(options);
if let Some(d) = default {
select = select.default(d);
}
let idx = select.interact().context("reading selection")?;
Ok(options[idx].clone())
}
fn handle_multi_select(
message: &str,
options: &[String],
defaults: Option<&[usize]>,
) -> Result<Vec<String>> {
if !crate::utils::is_interactive() {
let indices = defaults.unwrap_or(&[]);
return Ok(indices
.iter()
.filter(|&&i| i < options.len())
.map(|&i| options[i].clone())
.collect());
}
let theme = dialoguer::theme::ColorfulTheme::default();
let mut select = dialoguer::MultiSelect::with_theme(&theme)
.with_prompt(message)
.items(options);
if let Some(d) = defaults {
let bools: Vec<bool> = (0..options.len()).map(|i| d.contains(&i)).collect();
select = select.defaults(&bools);
}
let indices = select.interact().context("reading multi-selection")?;
Ok(indices.into_iter().map(|i| options[i].clone()).collect())
}
fn handle_progress(
bar: &mut Option<ProgressBar>,
plugin_name: &str,
message: Option<&str>,
current: Option<u64>,
total: Option<u64>,
done: bool,
) {
if done {
clear_progress(bar);
return;
}
let msg = message.unwrap_or("Working");
match (current, total) {
(Some(cur), Some(tot)) => {
let pb = bar.get_or_insert_with(|| {
let pb = ProgressBar::new(tot);
pb.set_style(
ProgressStyle::default_bar()
.template(&format!(
" {} {{msg}} [{{bar:30}}] {{pos}}/{{len}}",
style("▶").cyan().bold()
))
.expect("valid bar template")
.progress_chars("==> "),
);
pb
});
pb.set_length(tot);
pb.set_position(cur);
pb.set_message(format!("{} ({})", msg, plugin_name));
}
_ => {
if bar.is_none() {
let sp = ProgressBar::new_spinner();
sp.set_style(
ProgressStyle::default_spinner()
.template(&format!(
" {} {{msg}} {{spinner}}",
style("▶").cyan().bold()
))
.expect("valid spinner template"),
);
sp.enable_steady_tick(Duration::from_millis(100));
*bar = Some(sp);
}
if let Some(pb) = bar.as_ref() {
pb.set_message(format!("{} ({})", msg, plugin_name));
}
}
}
}
fn clear_progress(bar: &mut Option<ProgressBar>) {
if let Some(pb) = bar.take() {
pb.finish_and_clear();
}
}
fn handle_log(plugin_name: &str, level: &str, message: &str) {
let prefix = match level {
"debug" => style("DEBUG").dim(),
"info" => style("INFO").cyan(),
"warn" => style("WARN").yellow(),
"error" => style("ERROR").red().bold(),
_ => style(level).dim(),
};
eprintln!(" {} [{}] {}", prefix, style(plugin_name).dim(), message);
}
const MAX_STORE_KEY_SIZE: usize = 256;
const MAX_STORE_VALUE_SIZE: usize = 64 * 1024; const MAX_STORE_TOTAL_SIZE: usize = 1024 * 1024; const MAX_STORE_KEY_COUNT: usize = 256;
fn handle_store(plugin_dir: &Path, key: &str, value: &str) -> Result<()> {
if key.is_empty() {
bail!("store key must not be empty");
}
if key.len() > MAX_STORE_KEY_SIZE {
bail!(
"store key exceeds maximum size of {} bytes",
MAX_STORE_KEY_SIZE
);
}
if key.bytes().any(|b| b < 0x20 || b == 0x7f) {
bail!("store key contains control characters");
}
if value.len() > MAX_STORE_VALUE_SIZE {
bail!(
"store value exceeds maximum size of {} bytes",
MAX_STORE_VALUE_SIZE
);
}
fs::create_dir_all(plugin_dir).context("creating plugin directory")?;
let state_path = plugin_dir.join("state.json");
let lock_path = plugin_dir.join("state.json.lock");
let lock_file = fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&lock_path)
.context("opening state lock file")?;
lock_file
.lock()
.context("acquiring exclusive lock on state.json")?;
let mut state: HashMap<String, String> = if state_path.exists() {
let content = fs::read_to_string(&state_path).unwrap_or_default();
serde_json::from_str(&content).unwrap_or_default()
} else {
HashMap::new()
};
if !state.contains_key(key) && state.len() >= MAX_STORE_KEY_COUNT {
bail!(
"plugin state exceeds maximum of {} keys",
MAX_STORE_KEY_COUNT
);
}
state.insert(key.to_string(), value.to_string());
let json = serde_json::to_string_pretty(&state).context("serializing state")?;
if json.len() > MAX_STORE_TOTAL_SIZE {
bail!(
"plugin state exceeds maximum total size of {} bytes",
MAX_STORE_TOTAL_SIZE
);
}
let tmp_path = plugin_dir.join("state.json.tmp");
fs::write(&tmp_path, &json).context("writing temporary state file")?;
#[cfg(windows)]
{
let _ = fs::remove_file(&state_path);
}
fs::rename(&tmp_path, &state_path).context("replacing state.json")?;
lock_file.unlock().context("releasing state.json lock")?;
Ok(())
}
fn handle_load(plugin_dir: &Path, key: &str) -> Result<serde_json::Value> {
let state_path = plugin_dir.join("state.json");
let lock_path = plugin_dir.join("state.json.lock");
let lock_file = fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&lock_path)
.context("opening state lock file")?;
lock_file
.lock_shared()
.context("acquiring shared lock on state.json")?;
if !state_path.exists() {
lock_file.unlock().context("releasing state.json lock")?;
return Ok(serde_json::Value::Null);
}
let content = fs::read_to_string(&state_path).context("reading state.json")?;
lock_file.unlock().context("releasing state.json lock")?;
let state: HashMap<String, String> = serde_json::from_str(&content).unwrap_or_default();
match state.get(key) {
Some(v) => Ok(serde_json::Value::String(v.clone())),
None => Ok(serde_json::Value::Null),
}
}
fn handle_exec(
command: &str,
cwd: Option<&str>,
timeout: Option<u64>,
plugin_dir: &Path,
) -> Result<serde_json::Value> {
let project_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let work_dir = match cwd {
Some(dir) => {
let resolved = project_root.join(dir);
let canonical = match resolved.canonicalize() {
Ok(p) => p,
Err(_) => {
return Ok(serde_json::json!({
"code": 1,
"stdout": "",
"stderr": format!("exec cwd '{}' does not exist or is not accessible", dir)
}));
}
};
let canonical_root = project_root
.canonicalize()
.unwrap_or_else(|_| project_root.clone());
let canonical_plugin = plugin_dir
.canonicalize()
.unwrap_or_else(|_| plugin_dir.to_path_buf());
if !canonical.starts_with(&canonical_root) && !canonical.starts_with(&canonical_plugin)
{
return Ok(serde_json::json!({
"code": 1,
"stdout": "",
"stderr": "exec cwd escapes project and plugin directory"
}));
}
canonical
}
None => project_root.clone(),
};
const MAX_EXEC_TIMEOUT: u64 = 300;
let timeout_secs = timeout.unwrap_or(30).min(MAX_EXEC_TIMEOUT);
#[cfg(windows)]
let mut child = Command::new("cmd")
.args(["/C", command])
.current_dir(&work_dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("spawning exec command: {command}"))?;
#[cfg(not(windows))]
let mut child = {
use std::os::unix::process::CommandExt;
Command::new("sh")
.args(["-c", command])
.current_dir(&work_dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.process_group(0)
.spawn()
.with_context(|| format!("spawning exec command: {command}"))?
};
let result = wait_with_timeout(&mut child, Duration::from_secs(timeout_secs));
match result {
Ok(output) => Ok(serde_json::json!({
"code": output.status.code().unwrap_or(1),
"stdout": String::from_utf8_lossy(&output.stdout),
"stderr": String::from_utf8_lossy(&output.stderr),
})),
Err(_) => {
kill_child(&mut child);
Ok(serde_json::json!({
"code": 124,
"stdout": "",
"stderr": format!("command timed out after {}s", timeout_secs)
}))
}
}
}
fn handle_metadata(keys: &[String]) -> Result<serde_json::Value> {
let mut result = serde_json::Map::new();
for key in keys {
match key.as_str() {
"fledge_config" => {
let config_path = std::env::current_dir()
.unwrap_or_default()
.join("fledge.toml");
if config_path.exists() {
if let Ok(content) = fs::read_to_string(&config_path) {
if let Ok(parsed) = content.parse::<toml::Value>() {
result.insert(
key.clone(),
serde_json::to_value(parsed).unwrap_or(serde_json::Value::Null),
);
continue;
}
}
}
result.insert(key.clone(), serde_json::Value::Null);
}
"git_tags" => {
let tags: Vec<String> = Command::new("git")
.args(["tag", "--sort=-v:refname", "--no-column"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.lines()
.take(100)
.map(String::from)
.collect()
})
.unwrap_or_default();
result.insert(
key.clone(),
serde_json::to_value(tags).unwrap_or(serde_json::Value::Null),
);
}
"git_status" => {
let files: Vec<String> = Command::new("git")
.args(["status", "--porcelain"])
.output()
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.lines()
.map(String::from)
.collect()
})
.unwrap_or_default();
result.insert(
key.clone(),
serde_json::to_value(files).unwrap_or(serde_json::Value::Null),
);
}
"git_log" => {
let entries: Vec<String> = Command::new("git")
.args(["log", "--oneline", "-20"])
.output()
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.lines()
.map(String::from)
.collect()
})
.unwrap_or_default();
result.insert(
key.clone(),
serde_json::to_value(entries).unwrap_or(serde_json::Value::Null),
);
}
"env" => {
let sensitive_patterns = [
"secret",
"token",
"password",
"key",
"credential",
"auth",
"private",
"session",
"cookie",
];
let dangerous_prefixes = ["ld_preload", "ld_library_path", "dyld_", "kubeconfig"];
let safe_vars: HashMap<String, String> = std::env::vars()
.filter(|(k, v)| {
let lower = k.to_lowercase();
let is_sensitive_name =
sensitive_patterns.iter().any(|p| lower.contains(p));
let is_dangerous_prefix =
dangerous_prefixes.iter().any(|p| lower.starts_with(p));
let looks_like_conn_string = lower.ends_with("_url")
|| lower.ends_with("_uri")
|| lower.ends_with("_dsn");
let value_has_creds = v.contains('@') && v.contains(':');
!is_sensitive_name
&& !is_dangerous_prefix
&& !looks_like_conn_string
&& !value_has_creds
})
.collect();
result.insert(
key.clone(),
serde_json::to_value(safe_vars).unwrap_or(serde_json::Value::Null),
);
}
_ => {
result.insert(key.clone(), serde_json::Value::Null);
}
}
}
Ok(serde_json::Value::Object(result))
}
fn detect_project_context() -> Option<ProjectContext> {
let root = std::env::current_dir().ok()?;
let language = crate::run::detect_project_type(&root).to_string();
let name = root
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
let git = detect_git_context(&root);
Some(ProjectContext {
name,
root: root.to_string_lossy().to_string(),
language,
git,
})
}
fn sanitize_remote_url(url: &str) -> String {
if let Some(rest) = url.strip_prefix("https://") {
if let Some(at_pos) = rest.find('@') {
return format!("https://{}", &rest[at_pos + 1..]);
}
} else if let Some(rest) = url.strip_prefix("http://") {
if let Some(at_pos) = rest.find('@') {
return format!("http://{}", &rest[at_pos + 1..]);
}
}
url.to_string()
}
fn detect_git_context(root: &Path) -> Option<GitContext> {
let branch = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(root)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())?;
let dirty = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(root)
.output()
.ok()
.map(|o| !o.stdout.is_empty())
.unwrap_or(false);
let remote = Command::new("git")
.args(["remote"])
.current_dir(root)
.output()
.ok()
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.lines()
.next()
.unwrap_or("origin")
.to_string()
})
.unwrap_or_else(|| "origin".to_string());
let remote_url = Command::new("git")
.args(["remote", "get-url", &remote])
.current_dir(root)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| sanitize_remote_url(String::from_utf8_lossy(&o.stdout).trim()))
.unwrap_or_default();
Some(GitContext {
branch,
dirty,
remote,
remote_url,
})
}
fn wait_with_timeout(child: &mut Child, timeout: Duration) -> Result<std::process::Output, ()> {
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
if let Some(mut out) = child.stdout.take() {
std::io::Read::read_to_end(&mut out, &mut stdout).ok();
}
if let Some(mut err) = child.stderr.take() {
std::io::Read::read_to_end(&mut err, &mut stderr).ok();
}
let status = child
.wait()
.or_else(|_| std::process::Command::new("true").status())
.unwrap_or_else(|_| {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
std::process::ExitStatus::from_raw(1)
}
#[cfg(not(unix))]
{
std::process::Command::new("cmd")
.args(["/C", "exit", "1"])
.status()
.expect("cannot create exit status")
}
});
return Ok(std::process::Output {
status,
stdout,
stderr,
});
}
Ok(None) => {
if start.elapsed() > timeout {
return Err(());
}
std::thread::sleep(Duration::from_millis(50));
}
Err(_) => return Err(()),
}
}
}
fn kill_child(child: &mut Child) {
#[cfg(unix)]
{
let pid = child.id() as libc::pid_t;
unsafe {
libc::killpg(pid, libc::SIGKILL);
}
}
child.kill().ok();
child.wait().ok();
}
#[cfg(test)]
mod tests {
use super::*;
fn all_capabilities() -> crate::plugin::PluginCapabilities {
crate::plugin::PluginCapabilities {
exec: true,
store: true,
metadata: true,
}
}
fn no_capabilities() -> crate::plugin::PluginCapabilities {
crate::plugin::PluginCapabilities {
exec: false,
store: false,
metadata: false,
}
}
#[test]
fn parse_prompt_message() {
let json = r#"{"type":"prompt","id":"1","message":"Deploy target:","default":"staging"}"#;
let msg: OutboundMessage = serde_json::from_str(json).unwrap();
match msg {
OutboundMessage::Prompt {
id,
message,
default,
validate,
} => {
assert_eq!(id, "1");
assert_eq!(message, "Deploy target:");
assert_eq!(default, Some("staging".to_string()));
assert!(validate.is_none());
}
_ => panic!("expected Prompt"),
}
}
#[test]
fn parse_confirm_message() {
let json = r#"{"type":"confirm","id":"2","message":"Deploy?","default":false}"#;
let msg: OutboundMessage = serde_json::from_str(json).unwrap();
match msg {
OutboundMessage::Confirm {
id,
message,
default,
} => {
assert_eq!(id, "2");
assert_eq!(message, "Deploy?");
assert_eq!(default, Some(false));
}
_ => panic!("expected Confirm"),
}
}
#[test]
fn parse_select_message() {
let json =
r#"{"type":"select","id":"3","message":"Choose:","options":["a","b","c"],"default":1}"#;
let msg: OutboundMessage = serde_json::from_str(json).unwrap();
match msg {
OutboundMessage::Select {
id,
message,
options,
default,
} => {
assert_eq!(id, "3");
assert_eq!(message, "Choose:");
assert_eq!(options, vec!["a", "b", "c"]);
assert_eq!(default, Some(1));
}
_ => panic!("expected Select"),
}
}
#[test]
fn parse_multi_select_message() {
let json = r#"{"type":"multi_select","id":"4","message":"Pick:","options":["x","y"],"defaults":[0]}"#;
let msg: OutboundMessage = serde_json::from_str(json).unwrap();
match msg {
OutboundMessage::MultiSelect {
id,
message,
options,
defaults,
} => {
assert_eq!(id, "4");
assert_eq!(message, "Pick:");
assert_eq!(options, vec!["x", "y"]);
assert_eq!(defaults, Some(vec![0]));
}
_ => panic!("expected MultiSelect"),
}
}
#[test]
fn parse_progress_message() {
let json = r#"{"type":"progress","message":"Uploading","current":3,"total":10}"#;
let msg: OutboundMessage = serde_json::from_str(json).unwrap();
match msg {
OutboundMessage::Progress {
message,
current,
total,
done,
} => {
assert_eq!(message, Some("Uploading".to_string()));
assert_eq!(current, Some(3));
assert_eq!(total, Some(10));
assert_eq!(done, None);
}
_ => panic!("expected Progress"),
}
}
#[test]
fn parse_progress_done() {
let json = r#"{"type":"progress","done":true}"#;
let msg: OutboundMessage = serde_json::from_str(json).unwrap();
match msg {
OutboundMessage::Progress { done, .. } => {
assert_eq!(done, Some(true));
}
_ => panic!("expected Progress"),
}
}
#[test]
fn parse_log_message() {
let json = r#"{"type":"log","level":"warn","message":"No config found"}"#;
let msg: OutboundMessage = serde_json::from_str(json).unwrap();
match msg {
OutboundMessage::Log { level, message } => {
assert_eq!(level, "warn");
assert_eq!(message, "No config found");
}
_ => panic!("expected Log"),
}
}
#[test]
fn parse_output_message() {
let json = r#"{"type":"output","text":"Deployed in 4.2s\n"}"#;
let msg: OutboundMessage = serde_json::from_str(json).unwrap();
match msg {
OutboundMessage::Output { text } => {
assert_eq!(text, "Deployed in 4.2s\n");
}
_ => panic!("expected Output"),
}
}
#[test]
fn parse_store_message() {
let json = r#"{"type":"store","key":"last_target","value":"prod"}"#;
let msg: OutboundMessage = serde_json::from_str(json).unwrap();
match msg {
OutboundMessage::Store { key, value } => {
assert_eq!(key, "last_target");
assert_eq!(value, "prod");
}
_ => panic!("expected Store"),
}
}
#[test]
fn parse_load_message() {
let json = r#"{"type":"load","id":"5","key":"last_target"}"#;
let msg: OutboundMessage = serde_json::from_str(json).unwrap();
match msg {
OutboundMessage::Load { id, key } => {
assert_eq!(id, "5");
assert_eq!(key, "last_target");
}
_ => panic!("expected Load"),
}
}
#[test]
fn parse_exec_message() {
let json = r#"{"type":"exec","id":"6","command":"git tag -l","timeout":10}"#;
let msg: OutboundMessage = serde_json::from_str(json).unwrap();
match msg {
OutboundMessage::Exec {
id,
command,
cwd,
timeout,
} => {
assert_eq!(id, "6");
assert_eq!(command, "git tag -l");
assert!(cwd.is_none());
assert_eq!(timeout, Some(10));
}
_ => panic!("expected Exec"),
}
}
#[test]
fn parse_metadata_message() {
let json = r#"{"type":"metadata","id":"7","keys":["git_tags","git_status"]}"#;
let msg: OutboundMessage = serde_json::from_str(json).unwrap();
match msg {
OutboundMessage::Metadata { id, keys } => {
assert_eq!(id, "7");
assert_eq!(keys, vec!["git_tags", "git_status"]);
}
_ => panic!("expected Metadata"),
}
}
#[test]
fn malformed_json_is_rejected() {
let json = r#"this is not json"#;
let result: Result<OutboundMessage, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn unknown_type_is_rejected() {
let json = r#"{"type":"unknown_future_type","id":"99"}"#;
let result: Result<OutboundMessage, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn store_and_load_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
handle_store(tmp.path(), "test_key", "test_value").unwrap();
let value = handle_load(tmp.path(), "test_key").unwrap();
assert_eq!(value, serde_json::Value::String("test_value".to_string()));
}
#[test]
fn load_missing_key_returns_null() {
let tmp = tempfile::tempdir().unwrap();
let value = handle_load(tmp.path(), "nonexistent").unwrap();
assert_eq!(value, serde_json::Value::Null);
}
#[test]
fn load_missing_state_file_returns_null() {
let tmp = tempfile::tempdir().unwrap();
let value = handle_load(tmp.path(), "anything").unwrap();
assert_eq!(value, serde_json::Value::Null);
}
#[test]
fn store_overwrites_existing() {
let tmp = tempfile::tempdir().unwrap();
handle_store(tmp.path(), "key", "first").unwrap();
handle_store(tmp.path(), "key", "second").unwrap();
let value = handle_load(tmp.path(), "key").unwrap();
assert_eq!(value, serde_json::Value::String("second".to_string()));
}
#[test]
fn store_multiple_keys() {
let tmp = tempfile::tempdir().unwrap();
handle_store(tmp.path(), "a", "1").unwrap();
handle_store(tmp.path(), "b", "2").unwrap();
assert_eq!(
handle_load(tmp.path(), "a").unwrap(),
serde_json::Value::String("1".to_string())
);
assert_eq!(
handle_load(tmp.path(), "b").unwrap(),
serde_json::Value::String("2".to_string())
);
}
#[test]
fn init_message_serializes() {
let ctx = PluginContext {
msg_type: "init",
protocol: "fledge-v1",
args: vec!["--dry-run".to_string()],
project: Some(ProjectContext {
name: "test".to_string(),
root: "/tmp/test".to_string(),
language: "rust".to_string(),
git: Some(GitContext {
branch: "main".to_string(),
dirty: false,
remote: "origin".to_string(),
remote_url: "https://github.com/test/test".to_string(),
}),
}),
plugin: PluginInfo {
name: "fledge-test".to_string(),
version: "0.1.0".to_string(),
dir: "/tmp/plugins/fledge-test".to_string(),
},
fledge: FledgeInfo {
version: "0.9.1".to_string(),
},
capabilities: CapabilitiesInfo {
exec: true,
store: true,
metadata: false,
},
};
let json = serde_json::to_string(&ctx).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["type"], "init");
assert_eq!(parsed["capabilities"]["exec"], true);
assert_eq!(parsed["capabilities"]["store"], true);
assert_eq!(parsed["capabilities"]["metadata"], false);
assert_eq!(parsed["protocol"], "fledge-v1");
assert_eq!(parsed["args"][0], "--dry-run");
assert_eq!(parsed["project"]["name"], "test");
assert_eq!(parsed["project"]["git"]["branch"], "main");
}
#[test]
fn response_serializes_correctly() {
let resp = InboundResponse {
msg_type: "response",
id: "42".to_string(),
value: serde_json::Value::String("hello".to_string()),
};
let json = serde_json::to_string(&resp).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["type"], "response");
assert_eq!(parsed["id"], "42");
assert_eq!(parsed["value"], "hello");
}
#[test]
fn exec_sandbox_blocks_path_escape() {
let tmp = tempfile::tempdir().unwrap();
let result = handle_exec("echo hi", Some("../../.."), None, tmp.path()).unwrap();
let code = result["code"].as_i64().unwrap();
assert_ne!(code, 0);
}
#[test]
fn exec_runs_simple_command() {
let tmp = tempfile::tempdir().unwrap();
let result = handle_exec("echo hello", None, None, tmp.path()).unwrap();
assert_eq!(result["code"].as_i64().unwrap(), 0);
assert!(result["stdout"].as_str().unwrap().contains("hello"));
}
#[test]
fn metadata_handles_unknown_keys() {
let result = handle_metadata(&["nonexistent_key".to_string()]).unwrap();
assert_eq!(result["nonexistent_key"], serde_json::Value::Null);
}
fn compile_test_plugin(src: &str, tmp: &Path) -> PathBuf {
let src_path = tmp.join("test_plugin.rs");
std::fs::write(&src_path, src).unwrap();
let bin_name = if cfg!(windows) {
"test_plugin.exe"
} else {
"test_plugin"
};
let bin_path = tmp.join(bin_name);
let output = std::process::Command::new("rustc")
.args([src_path.to_str().unwrap(), "-o", bin_path.to_str().unwrap()])
.output()
.expect("rustc must be available to run plugin tests");
assert!(
output.status.success(),
"rustc failed: {}",
String::from_utf8_lossy(&output.stderr)
);
bin_path
}
#[test]
fn run_protocol_plugin_store_load() {
let tmp = tempfile::tempdir().unwrap();
let bin = compile_test_plugin(
r#"
use std::io::{self, BufRead, Write};
fn send(msg: &str) { println!("{}", msg); io::stdout().flush().unwrap(); }
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let _init = lines.next().unwrap().unwrap();
send("{\"type\":\"log\",\"level\":\"info\",\"message\":\"test started\"}");
send("{\"type\":\"store\",\"key\":\"test_key\",\"value\":\"test_value\"}");
send("{\"type\":\"load\",\"id\":\"load1\",\"key\":\"test_key\"}");
let _resp = lines.next().unwrap().unwrap();
send("{\"type\":\"output\",\"text\":\"done\\n\"}");
}
"#,
tmp.path(),
);
let store_dir = tempfile::tempdir().unwrap();
let result = super::run_protocol_plugin(
&bin,
&[],
"test-plugin",
"0.1.0",
store_dir.path(),
&all_capabilities(),
);
assert!(result.is_ok(), "protocol plugin failed: {:?}", result.err());
let state_path = store_dir.path().join("state.json");
assert!(state_path.exists(), "store should have created state.json");
let state: std::collections::HashMap<String, String> =
serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap();
assert_eq!(
state.get("test_key").map(|s| s.as_str()),
Some("test_value")
);
}
#[test]
fn run_protocol_plugin_exec_and_metadata() {
let tmp = tempfile::tempdir().unwrap();
let bin = compile_test_plugin(
r#"
use std::io::{self, BufRead, Write};
fn send(msg: &str) { println!("{}", msg); io::stdout().flush().unwrap(); }
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let _init = lines.next().unwrap().unwrap();
// Test exec: run a simple cross-platform command
send("{\"type\":\"exec\",\"id\":\"e1\",\"command\":\"echo hello_from_plugin\"}");
let exec_resp = lines.next().unwrap().unwrap();
assert!(exec_resp.contains("\"id\":\"e1\""), "response should echo id");
assert!(exec_resp.contains("hello_from_plugin"), "exec stdout missing: {}", exec_resp);
// Test metadata
send("{\"type\":\"metadata\",\"id\":\"m1\",\"keys\":[\"env\"]}");
let meta_resp = lines.next().unwrap().unwrap();
assert!(meta_resp.contains("\"id\":\"m1\""), "metadata response should echo id");
assert!(meta_resp.contains("\"env\""), "metadata should contain env key");
send("{\"type\":\"output\",\"text\":\"exec+metadata ok\\n\"}");
}
"#,
tmp.path(),
);
let store_dir = tempfile::tempdir().unwrap();
let result = super::run_protocol_plugin(
&bin,
&[],
"test-exec",
"0.1.0",
store_dir.path(),
&all_capabilities(),
);
assert!(
result.is_ok(),
"exec/metadata test failed: {:?}",
result.err()
);
}
#[test]
fn run_protocol_plugin_graceful_exit_no_messages() {
let tmp = tempfile::tempdir().unwrap();
let bin = compile_test_plugin(
r#"
use std::io::{self, BufRead};
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let _init = lines.next().unwrap().unwrap();
// Exit immediately without sending anything
}
"#,
tmp.path(),
);
let store_dir = tempfile::tempdir().unwrap();
let result = super::run_protocol_plugin(
&bin,
&[],
"test-noop",
"0.1.0",
store_dir.path(),
&all_capabilities(),
);
assert!(
result.is_ok(),
"noop plugin should succeed: {:?}",
result.err()
);
}
#[test]
fn run_protocol_plugin_nonzero_exit_is_error() {
let tmp = tempfile::tempdir().unwrap();
let bin = compile_test_plugin(
r#"
use std::io::{self, BufRead};
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let _init = lines.next().unwrap().unwrap();
std::process::exit(42);
}
"#,
tmp.path(),
);
let store_dir = tempfile::tempdir().unwrap();
let result = super::run_protocol_plugin(
&bin,
&[],
"test-fail",
"0.1.0",
store_dir.path(),
&all_capabilities(),
);
assert!(result.is_err(), "nonzero exit should be an error");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("42"),
"error should mention exit code: {err_msg}"
);
}
#[test]
fn run_protocol_plugin_malformed_json_skipped() {
let tmp = tempfile::tempdir().unwrap();
let bin = compile_test_plugin(
r#"
use std::io::{self, BufRead, Write};
fn send(msg: &str) { println!("{}", msg); io::stdout().flush().unwrap(); }
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let _init = lines.next().unwrap().unwrap();
// Send garbage — should be skipped, not crash
send("this is not json at all");
send("{malformed");
send("{\"type\":\"unknown_future_type\",\"id\":\"x\"}");
// Then send valid messages
send("{\"type\":\"store\",\"key\":\"survived\",\"value\":\"yes\"}");
send("{\"type\":\"output\",\"text\":\"still alive\\n\"}");
}
"#,
tmp.path(),
);
let store_dir = tempfile::tempdir().unwrap();
let result = super::run_protocol_plugin(
&bin,
&[],
"test-malformed",
"0.1.0",
store_dir.path(),
&all_capabilities(),
);
assert!(
result.is_ok(),
"malformed JSON should be skipped: {:?}",
result.err()
);
let state: std::collections::HashMap<String, String> = serde_json::from_str(
&std::fs::read_to_string(store_dir.path().join("state.json")).unwrap(),
)
.unwrap();
assert_eq!(state.get("survived").map(|s| s.as_str()), Some("yes"));
}
#[test]
fn run_protocol_plugin_multiple_store_load_cycles() {
let tmp = tempfile::tempdir().unwrap();
let bin = compile_test_plugin(
r#"
use std::io::{self, BufRead, Write};
fn send(msg: &str) { println!("{}", msg); io::stdout().flush().unwrap(); }
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let _init = lines.next().unwrap().unwrap();
// Store multiple keys
send("{\"type\":\"store\",\"key\":\"a\",\"value\":\"1\"}");
send("{\"type\":\"store\",\"key\":\"b\",\"value\":\"2\"}");
send("{\"type\":\"store\",\"key\":\"a\",\"value\":\"3\"}");
// Load them back and verify via string matching
send("{\"type\":\"load\",\"id\":\"la\",\"key\":\"a\"}");
let resp_a = lines.next().unwrap().unwrap();
assert!(resp_a.contains("\"id\":\"la\""), "response should echo id la");
assert!(resp_a.contains("\"3\""), "overwritten value should be 3: {}", resp_a);
send("{\"type\":\"load\",\"id\":\"lb\",\"key\":\"b\"}");
let resp_b = lines.next().unwrap().unwrap();
assert!(resp_b.contains("\"2\""), "b should be 2: {}", resp_b);
// Load nonexistent key — should get null
send("{\"type\":\"load\",\"id\":\"lc\",\"key\":\"nonexistent\"}");
let resp_c = lines.next().unwrap().unwrap();
assert!(resp_c.contains("null"), "missing key should return null: {}", resp_c);
}
"#,
tmp.path(),
);
let store_dir = tempfile::tempdir().unwrap();
let result = super::run_protocol_plugin(
&bin,
&[],
"test-multi-store",
"0.1.0",
store_dir.path(),
&all_capabilities(),
);
assert!(
result.is_ok(),
"multi store/load failed: {:?}",
result.err()
);
}
#[test]
fn run_protocol_plugin_receives_init_context() {
let tmp = tempfile::tempdir().unwrap();
let bin = compile_test_plugin(
r#"
use std::io::{self, BufRead, Write};
fn send(msg: &str) { println!("{}", msg); io::stdout().flush().unwrap(); }
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let init_line = lines.next().unwrap().unwrap();
// Verify init message structure via string matching (no serde_json in standalone rustc)
assert!(init_line.contains("\"type\":\"init\""), "missing type:init");
assert!(init_line.contains("\"protocol\":\"fledge-v1\""), "missing protocol");
assert!(init_line.contains("\"plugin\""), "missing plugin field");
assert!(init_line.contains("\"fledge\""), "missing fledge field");
assert!(init_line.contains("\"name\""), "missing plugin name");
assert!(init_line.contains("\"version\""), "missing version");
send("{\"type\":\"log\",\"level\":\"info\",\"message\":\"init validated\"}");
}
"#,
tmp.path(),
);
let store_dir = tempfile::tempdir().unwrap();
let result = super::run_protocol_plugin(
&bin,
&[],
"test-init",
"0.1.0",
store_dir.path(),
&all_capabilities(),
);
assert!(
result.is_ok(),
"init context test failed: {:?}",
result.err()
);
}
#[test]
fn run_protocol_plugin_exec_sandbox_blocks_escape() {
let tmp = tempfile::tempdir().unwrap();
let bin = compile_test_plugin(
r#"
use std::io::{self, BufRead, Write};
fn send(msg: &str) { println!("{}", msg); io::stdout().flush().unwrap(); }
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let _init = lines.next().unwrap().unwrap();
// Try to escape sandbox with nonexistent path traversal
send("{\"type\":\"exec\",\"id\":\"e1\",\"command\":\"echo pwned\",\"cwd\":\"../../..\"}");
let resp = lines.next().unwrap().unwrap();
assert!(!resp.contains("\"code\":0"), "sandbox escape should be blocked: {}", resp);
assert!(
resp.contains("does not exist") || resp.contains("escapes project"),
"expected sandbox error message, got: {}", resp
);
}
"#,
tmp.path(),
);
let store_dir = tempfile::tempdir().unwrap();
let result = super::run_protocol_plugin(
&bin,
&[],
"test-sandbox",
"0.1.0",
store_dir.path(),
&all_capabilities(),
);
assert!(result.is_ok(), "sandbox test failed: {:?}", result.err());
}
#[test]
fn run_protocol_plugin_exec_timeout_returns_code_124() {
let tmp = tempfile::tempdir().unwrap();
let bin = compile_test_plugin(
r#"
use std::io::{self, BufRead, Write};
fn send(msg: &str) { println!("{}", msg); io::stdout().flush().unwrap(); }
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let _init = lines.next().unwrap().unwrap();
// Request exec with a command that sleeps longer than the timeout
send("{\"type\":\"exec\",\"id\":\"t1\",\"command\":\"sleep 30\",\"timeout\":1}");
let resp = lines.next().unwrap().unwrap();
assert!(resp.contains("\"code\":124"), "expected timeout code 124, got: {}", resp);
assert!(resp.contains("timed out"), "expected timeout message, got: {}", resp);
}
"#,
tmp.path(),
);
let store_dir = tempfile::tempdir().unwrap();
let result = super::run_protocol_plugin(
&bin,
&[],
"test-timeout",
"0.1.0",
store_dir.path(),
&all_capabilities(),
);
assert!(result.is_ok(), "timeout test failed: {:?}", result.err());
}
#[test]
fn sanitize_remote_url_strips_credentials() {
assert_eq!(
super::sanitize_remote_url("https://token@github.com/org/repo.git"),
"https://github.com/org/repo.git"
);
assert_eq!(
super::sanitize_remote_url("https://user:pass@github.com/org/repo.git"),
"https://github.com/org/repo.git"
);
assert_eq!(
super::sanitize_remote_url("https://github.com/org/repo.git"),
"https://github.com/org/repo.git"
);
assert_eq!(
super::sanitize_remote_url("git@github.com:org/repo.git"),
"git@github.com:org/repo.git"
);
}
#[test]
fn store_rejects_empty_key() {
let tmp = tempfile::tempdir().unwrap();
let err = handle_store(tmp.path(), "", "value").unwrap_err();
assert!(
err.to_string().contains("must not be empty"),
"expected empty key error, got: {err}"
);
}
#[test]
fn store_rejects_control_characters_in_key() {
let tmp = tempfile::tempdir().unwrap();
let err = handle_store(tmp.path(), "bad\x00key", "value").unwrap_err();
assert!(
err.to_string().contains("control characters"),
"expected control char error, got: {err}"
);
let err2 = handle_store(tmp.path(), "bad\nkey", "value").unwrap_err();
assert!(
err2.to_string().contains("control characters"),
"expected control char error, got: {err2}"
);
}
#[test]
fn store_creates_lock_file() {
let tmp = tempfile::tempdir().unwrap();
handle_store(tmp.path(), "key", "value").unwrap();
assert!(tmp.path().join("state.json.lock").exists());
}
#[test]
fn store_atomic_write() {
let tmp = tempfile::tempdir().unwrap();
handle_store(tmp.path(), "key", "value").unwrap();
assert!(tmp.path().join("state.json").exists());
assert!(!tmp.path().join("state.json.tmp").exists());
}
#[test]
fn capability_blocks_exec() {
let tmp = tempfile::tempdir().unwrap();
let bin = compile_test_plugin(
r#"
use std::io::{self, BufRead, Write};
fn send(msg: &str) { println!("{}", msg); io::stdout().flush().unwrap(); }
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let _init = lines.next().unwrap().unwrap();
send("{\"type\":\"exec\",\"id\":\"e1\",\"command\":\"echo blocked\"}");
let resp = lines.next().unwrap().unwrap();
assert!(resp.contains("\"code\":126"), "exec should be blocked with code 126: {}", resp);
assert!(resp.contains("not granted"), "should mention not granted: {}", resp);
}
"#,
tmp.path(),
);
let store_dir = tempfile::tempdir().unwrap();
let caps = no_capabilities();
let result =
super::run_protocol_plugin(&bin, &[], "test-no-exec", "0.1.0", store_dir.path(), &caps);
assert!(
result.is_ok(),
"blocked exec should not crash: {:?}",
result.err()
);
}
#[test]
fn capability_blocks_store() {
let tmp = tempfile::tempdir().unwrap();
let bin = compile_test_plugin(
r#"
use std::io::{self, BufRead, Write};
fn send(msg: &str) { println!("{}", msg); io::stdout().flush().unwrap(); }
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let _init = lines.next().unwrap().unwrap();
send("{\"type\":\"store\",\"key\":\"secret\",\"value\":\"data\"}");
send("{\"type\":\"load\",\"id\":\"l1\",\"key\":\"secret\"}");
let resp = lines.next().unwrap().unwrap();
assert!(resp.contains("null"), "load should return null when store blocked: {}", resp);
}
"#,
tmp.path(),
);
let store_dir = tempfile::tempdir().unwrap();
let caps = no_capabilities();
let result = super::run_protocol_plugin(
&bin,
&[],
"test-no-store",
"0.1.0",
store_dir.path(),
&caps,
);
assert!(
result.is_ok(),
"blocked store should not crash: {:?}",
result.err()
);
assert!(
!store_dir.path().join("state.json").exists(),
"state.json should not be created when store is blocked"
);
}
#[test]
fn capability_blocks_metadata() {
let tmp = tempfile::tempdir().unwrap();
let bin = compile_test_plugin(
r#"
use std::io::{self, BufRead, Write};
fn send(msg: &str) { println!("{}", msg); io::stdout().flush().unwrap(); }
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let _init = lines.next().unwrap().unwrap();
send("{\"type\":\"metadata\",\"id\":\"m1\",\"keys\":[\"env\"]}");
let resp = lines.next().unwrap().unwrap();
assert!(resp.contains("\"id\":\"m1\""), "should echo id: {}", resp);
// Should get empty object, not env data
assert!(!resp.contains("PATH"), "should not contain env data when blocked: {}", resp);
}
"#,
tmp.path(),
);
let store_dir = tempfile::tempdir().unwrap();
let caps = no_capabilities();
let result = super::run_protocol_plugin(
&bin,
&[],
"test-no-metadata",
"0.1.0",
store_dir.path(),
&caps,
);
assert!(
result.is_ok(),
"blocked metadata should not crash: {:?}",
result.err()
);
}
#[test]
fn init_message_includes_capabilities() {
let tmp = tempfile::tempdir().unwrap();
let bin = compile_test_plugin(
r#"
use std::io::{self, BufRead, Write};
fn send(msg: &str) { println!("{}", msg); io::stdout().flush().unwrap(); }
fn main() {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
let init_line = lines.next().unwrap().unwrap();
assert!(init_line.contains("\"capabilities\""), "init should contain capabilities: {}", init_line);
assert!(init_line.contains("\"exec\":true"), "should have exec:true: {}", init_line);
assert!(init_line.contains("\"store\":false"), "should have store:false: {}", init_line);
assert!(init_line.contains("\"metadata\":true"), "should have metadata:true: {}", init_line);
send("{\"type\":\"log\",\"level\":\"info\",\"message\":\"caps validated\"}");
}
"#,
tmp.path(),
);
let store_dir = tempfile::tempdir().unwrap();
let caps = crate::plugin::PluginCapabilities {
exec: true,
store: false,
metadata: true,
};
let result = super::run_protocol_plugin(
&bin,
&[],
"test-caps-init",
"0.1.0",
store_dir.path(),
&caps,
);
assert!(result.is_ok(), "caps init test failed: {:?}", result.err());
}
#[test]
fn capabilities_parse_from_toml() {
let caps_str = r#"
exec = true
store = true
metadata = false
"#;
let caps: crate::plugin::PluginCapabilities = toml::from_str(caps_str).unwrap();
assert!(caps.exec);
assert!(caps.store);
assert!(!caps.metadata);
}
#[test]
fn capabilities_default_to_false() {
let caps: crate::plugin::PluginCapabilities = toml::from_str("").unwrap();
assert!(!caps.exec);
assert!(!caps.store);
assert!(!caps.metadata);
}
}