use anyhow::{Context, Result};
use lowfat_plugin::manifest::RuntimeType;
use lowfat_plugin::plugin::{FilterInput, FilterOutput, FilterPlugin, PluginInfo};
use lowfat_plugin::security;
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
pub struct ProcessFilter {
pub info: PluginInfo,
pub runtime_type: RuntimeType,
pub entry: PathBuf,
pub base_dir: PathBuf,
pub custom_command: Option<String>,
pub input_format: String,
pub result_format: String,
}
impl FilterPlugin for ProcessFilter {
fn info(&self) -> PluginInfo {
self.info.clone()
}
fn filter(&self, input: &FilterInput) -> Result<FilterOutput> {
let (program, args) = self.build_command()?;
let safe_env = security::sanitized_env();
let mut child = Command::new(&program)
.args(&args)
.current_dir(&self.base_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env_clear()
.envs(safe_env)
.env("LOWFAT_LEVEL", input.level.to_string())
.env("RUNF_COMMAND", &input.command)
.env("RUNF_SUBCOMMAND", &input.subcommand)
.env("RUNF_EXIT_CODE", input.exit_code.to_string())
.spawn()
.with_context(|| format!("failed to spawn plugin: {program}"))?;
if let Some(mut stdin) = child.stdin.take() {
let payload = match self.input_format.as_str() {
"json" => serde_json::to_string(&serde_json::json!({
"version": 1,
"command": input.command,
"subcommand": input.subcommand,
"args": input.args,
"level": input.level.to_string(),
"head_limit": input.head_limit,
"exit_code": input.exit_code,
"raw": input.raw,
}))?,
_ => input.raw.clone(),
};
let _ = stdin.write_all(payload.as_bytes());
}
let output = child.wait_with_output()?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let text = match self.result_format.as_str() {
"json" => {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stdout) {
parsed
.get("output")
.and_then(|v| v.as_str())
.unwrap_or(&stdout)
.to_string()
} else {
stdout
}
}
_ => stdout,
};
Ok(FilterOutput {
passthrough: text.is_empty(),
text,
})
}
}
impl ProcessFilter {
fn build_command(&self) -> Result<(String, Vec<String>)> {
let entry = self.entry.to_string_lossy().to_string();
match self.runtime_type {
RuntimeType::Shell => Ok(("sh".into(), vec![entry])),
RuntimeType::Python => Ok(("python3".into(), vec![entry])),
RuntimeType::Node => Ok(("node".into(), vec![entry])),
RuntimeType::Deno => Ok(("deno".into(), vec!["run".into(), entry])),
RuntimeType::Ruby => Ok(("ruby".into(), vec![entry])),
RuntimeType::Lua => Ok(("lua".into(), vec![entry])),
RuntimeType::Binary => Ok((entry, vec![])),
RuntimeType::Custom => {
let template = self.custom_command.as_deref().unwrap_or(&entry);
let expanded = template.replace("{entry}", &entry);
let parts: Vec<&str> = expanded.split_whitespace().collect();
let program = parts.first().unwrap_or(&"").to_string();
let args: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();
Ok((program, args))
}
RuntimeType::Wasm => {
anyhow::bail!("WASM runtime not yet implemented")
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use lowfat_core::level::Level;
use std::io::Write;
fn make_input(raw: &str) -> FilterInput {
FilterInput {
raw: raw.to_string(),
command: "test".to_string(),
subcommand: "sub".to_string(),
args: vec!["arg1".to_string()],
level: Level::Full,
head_limit: 40,
exit_code: 0,
}
}
fn make_filter(runtime: RuntimeType, entry: &str, code: &str, json: bool) -> ProcessFilter {
let dir = std::env::temp_dir().join(format!("lowfat-test-{}-{}", entry, std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(entry);
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(code.as_bytes()).unwrap();
let fmt = if json { "json" } else { "raw" };
ProcessFilter {
info: PluginInfo {
name: "test-plugin".into(),
version: "0.1.0".into(),
commands: vec!["test".into()],
subcommands: vec![],
},
runtime_type: runtime,
entry: path,
base_dir: dir,
custom_command: None,
input_format: fmt.into(),
result_format: fmt.into(),
}
}
#[test]
fn shell_raw_filter() {
let filter = make_filter(
RuntimeType::Shell, "filter.sh",
"#!/bin/sh\ngrep -v '^warning:'",
false,
);
let input = make_input("ok line\nwarning: skip\nanother line");
let result = filter.filter(&input).unwrap();
assert_eq!(result.text.trim(), "ok line\nanother line");
assert!(!result.passthrough);
}
#[test]
fn node_json_filter() {
let code = r#"
const chunks = [];
process.stdin.on("data", d => chunks.push(d));
process.stdin.on("end", () => {
const input = JSON.parse(Buffer.concat(chunks).toString());
const filtered = input.raw.split("\n").filter(l => !l.startsWith("debug:")).join("\n");
process.stdout.write(JSON.stringify({ output: filtered }));
});
"#;
let filter = make_filter(RuntimeType::Node, "filter.js", code, true);
let input = make_input("info line\ndebug: skip\nresult");
let result = filter.filter(&input).unwrap();
assert_eq!(result.text, "info line\nresult");
}
#[test]
fn python_json_filter() {
let code = r#"
import json, sys
data = json.load(sys.stdin)
lines = [l for l in data["raw"].split("\n") if not l.startswith("debug:")]
json.dump({"output": "\n".join(lines)}, sys.stdout)
"#;
let filter = make_filter(RuntimeType::Python, "filter.py", code, true);
let input = make_input("info line\ndebug: skip\nresult");
let result = filter.filter(&input).unwrap();
assert_eq!(result.text, "info line\nresult");
}
#[test]
fn ruby_raw_filter() {
let code = r#"
$stdin.each_line do |line|
puts line unless line.start_with?("warning:")
end
"#;
let filter = make_filter(RuntimeType::Ruby, "filter.rb", code, false);
let input = make_input("ok line\nwarning: skip\nanother line");
let result = filter.filter(&input).unwrap();
assert_eq!(result.text.trim(), "ok line\nanother line");
}
#[test]
fn lua_raw_filter() {
let code = r#"
for line in io.lines() do
if not line:match("^warning:") then
print(line)
end
end
"#;
let filter = make_filter(RuntimeType::Lua, "filter.lua", code, false);
let input = make_input("ok line\nwarning: skip\nanother line");
let result = filter.filter(&input).unwrap();
assert_eq!(result.text.trim(), "ok line\nanother line");
}
#[test]
fn shell_env_vars() {
let code = "#!/bin/sh\necho \"level=$LOWFAT_LEVEL\"\necho \"cmd=$RUNF_COMMAND\"\necho \"sub=$RUNF_SUBCOMMAND\"\necho \"exit=$RUNF_EXIT_CODE\"";
let filter = make_filter(RuntimeType::Shell, "env.sh", code, false);
let input = make_input("ignored");
let result = filter.filter(&input).unwrap();
assert!(result.text.contains("level=full"));
assert!(result.text.contains("cmd=test"));
assert!(result.text.contains("sub=sub"));
assert!(result.text.contains("exit=0"));
}
#[test]
fn empty_output_passthrough() {
let code = "#!/bin/sh\n# output nothing";
let filter = make_filter(RuntimeType::Shell, "empty.sh", code, false);
let input = make_input("some input");
let result = filter.filter(&input).unwrap();
assert!(result.passthrough);
assert!(result.text.is_empty());
}
#[test]
fn node_json_context_fields() {
let code = r#"
const chunks = [];
process.stdin.on("data", d => chunks.push(d));
process.stdin.on("end", () => {
const input = JSON.parse(Buffer.concat(chunks).toString());
const out = `v=${input.version} cmd=${input.command} sub=${input.subcommand} lvl=${input.level} exit=${input.exit_code}`;
process.stdout.write(JSON.stringify({ output: out }));
});
"#;
let filter = make_filter(RuntimeType::Node, "ctx.js", code, true);
let input = make_input("raw data");
let result = filter.filter(&input).unwrap();
assert!(result.text.contains("v=1"));
assert!(result.text.contains("cmd=test"));
assert!(result.text.contains("sub=sub"));
assert!(result.text.contains("lvl=full"));
assert!(result.text.contains("exit=0"));
}
}