1use anyhow::{Context, Result};
2use lowfat_plugin::manifest::RuntimeType;
3use lowfat_plugin::plugin::{FilterInput, FilterOutput, FilterPlugin, PluginInfo};
4use lowfat_plugin::security;
5use std::io::Write;
6use std::path::PathBuf;
7use std::process::{Command, Stdio};
8
9pub struct ProcessFilter {
11 pub info: PluginInfo,
12 pub runtime_type: RuntimeType,
13 pub entry: PathBuf,
14 pub base_dir: PathBuf,
15 pub custom_command: Option<String>,
16 pub input_format: String,
17 pub result_format: String,
18}
19
20impl FilterPlugin for ProcessFilter {
21 fn info(&self) -> PluginInfo {
22 self.info.clone()
23 }
24
25 fn filter(&self, input: &FilterInput) -> Result<FilterOutput> {
26 let (program, args) = self.build_command()?;
27 let safe_env = security::sanitized_env();
28
29 let mut child = Command::new(&program)
30 .args(&args)
31 .current_dir(&self.base_dir)
32 .stdin(Stdio::piped())
33 .stdout(Stdio::piped())
34 .stderr(Stdio::piped())
35 .env_clear()
36 .envs(safe_env)
37 .env("LOWFAT_LEVEL", input.level.to_string())
38 .env("RUNF_COMMAND", &input.command)
39 .env("RUNF_SUBCOMMAND", &input.subcommand)
40 .env("RUNF_EXIT_CODE", input.exit_code.to_string())
41 .spawn()
42 .with_context(|| format!("failed to spawn plugin: {program}"))?;
43
44 if let Some(mut stdin) = child.stdin.take() {
45 let payload = match self.input_format.as_str() {
46 "json" => serde_json::to_string(&serde_json::json!({
47 "version": 1,
48 "command": input.command,
49 "subcommand": input.subcommand,
50 "args": input.args,
51 "level": input.level.to_string(),
52 "head_limit": input.head_limit,
53 "exit_code": input.exit_code,
54 "raw": input.raw,
55 }))?,
56 _ => input.raw.clone(),
57 };
58 let _ = stdin.write_all(payload.as_bytes());
59 }
60
61 let output = child.wait_with_output()?;
62 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
63
64 let text = match self.result_format.as_str() {
65 "json" => {
66 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stdout) {
67 parsed
68 .get("output")
69 .and_then(|v| v.as_str())
70 .unwrap_or(&stdout)
71 .to_string()
72 } else {
73 stdout
74 }
75 }
76 _ => stdout,
77 };
78
79 Ok(FilterOutput {
80 passthrough: text.is_empty(),
81 text,
82 })
83 }
84}
85
86impl ProcessFilter {
87 fn build_command(&self) -> Result<(String, Vec<String>)> {
88 let entry = self.entry.to_string_lossy().to_string();
89 match self.runtime_type {
90 RuntimeType::Shell => Ok(("sh".into(), vec![entry])),
91 RuntimeType::Python => Ok(("python3".into(), vec![entry])),
92 RuntimeType::Node => Ok(("node".into(), vec![entry])),
93 RuntimeType::Deno => Ok(("deno".into(), vec!["run".into(), entry])),
94 RuntimeType::Ruby => Ok(("ruby".into(), vec![entry])),
95 RuntimeType::Lua => Ok(("lua".into(), vec![entry])),
96 RuntimeType::Binary => Ok((entry, vec![])),
97 RuntimeType::Custom => {
98 let template = self.custom_command.as_deref().unwrap_or(&entry);
99 let expanded = template.replace("{entry}", &entry);
100 let parts: Vec<&str> = expanded.split_whitespace().collect();
101 let program = parts.first().unwrap_or(&"").to_string();
102 let args: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();
103 Ok((program, args))
104 }
105 RuntimeType::Wasm => {
106 anyhow::bail!("WASM runtime not yet implemented")
107 }
108 }
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115 use lowfat_core::level::Level;
116 use std::io::Write;
117
118 fn make_input(raw: &str) -> FilterInput {
119 FilterInput {
120 raw: raw.to_string(),
121 command: "test".to_string(),
122 subcommand: "sub".to_string(),
123 args: vec!["arg1".to_string()],
124 level: Level::Full,
125 head_limit: 40,
126 exit_code: 0,
127 }
128 }
129
130 fn make_filter(runtime: RuntimeType, entry: &str, code: &str, json: bool) -> ProcessFilter {
131 let dir = std::env::temp_dir().join(format!("lowfat-test-{}-{}", entry, std::process::id()));
132 std::fs::create_dir_all(&dir).unwrap();
133 let path = dir.join(entry);
134 let mut f = std::fs::File::create(&path).unwrap();
135 f.write_all(code.as_bytes()).unwrap();
136
137 let fmt = if json { "json" } else { "raw" };
138 ProcessFilter {
139 info: PluginInfo {
140 name: "test-plugin".into(),
141 version: "0.1.0".into(),
142 commands: vec!["test".into()],
143 subcommands: vec![],
144 },
145 runtime_type: runtime,
146 entry: path,
147 base_dir: dir,
148 custom_command: None,
149 input_format: fmt.into(),
150 result_format: fmt.into(),
151 }
152 }
153
154 #[test]
155 fn shell_raw_filter() {
156 let filter = make_filter(
157 RuntimeType::Shell, "filter.sh",
158 "#!/bin/sh\ngrep -v '^warning:'",
159 false,
160 );
161 let input = make_input("ok line\nwarning: skip\nanother line");
162 let result = filter.filter(&input).unwrap();
163 assert_eq!(result.text.trim(), "ok line\nanother line");
164 assert!(!result.passthrough);
165 }
166
167 #[test]
168 fn node_json_filter() {
169 let code = r#"
170const chunks = [];
171process.stdin.on("data", d => chunks.push(d));
172process.stdin.on("end", () => {
173 const input = JSON.parse(Buffer.concat(chunks).toString());
174 const filtered = input.raw.split("\n").filter(l => !l.startsWith("debug:")).join("\n");
175 process.stdout.write(JSON.stringify({ output: filtered }));
176});
177"#;
178 let filter = make_filter(RuntimeType::Node, "filter.js", code, true);
179 let input = make_input("info line\ndebug: skip\nresult");
180 let result = filter.filter(&input).unwrap();
181 assert_eq!(result.text, "info line\nresult");
182 }
183
184 #[test]
185 fn python_json_filter() {
186 let code = r#"
187import json, sys
188data = json.load(sys.stdin)
189lines = [l for l in data["raw"].split("\n") if not l.startswith("debug:")]
190json.dump({"output": "\n".join(lines)}, sys.stdout)
191"#;
192 let filter = make_filter(RuntimeType::Python, "filter.py", code, true);
193 let input = make_input("info line\ndebug: skip\nresult");
194 let result = filter.filter(&input).unwrap();
195 assert_eq!(result.text, "info line\nresult");
196 }
197
198 #[test]
199 fn ruby_raw_filter() {
200 let code = r#"
201$stdin.each_line do |line|
202 puts line unless line.start_with?("warning:")
203end
204"#;
205 let filter = make_filter(RuntimeType::Ruby, "filter.rb", code, false);
206 let input = make_input("ok line\nwarning: skip\nanother line");
207 let result = filter.filter(&input).unwrap();
208 assert_eq!(result.text.trim(), "ok line\nanother line");
209 }
210
211 #[test]
212 fn lua_raw_filter() {
213 let code = r#"
214for line in io.lines() do
215 if not line:match("^warning:") then
216 print(line)
217 end
218end
219"#;
220 let filter = make_filter(RuntimeType::Lua, "filter.lua", code, false);
221 let input = make_input("ok line\nwarning: skip\nanother line");
222 let result = filter.filter(&input).unwrap();
223 assert_eq!(result.text.trim(), "ok line\nanother line");
224 }
225
226 #[test]
227 fn shell_env_vars() {
228 let code = "#!/bin/sh\necho \"level=$LOWFAT_LEVEL\"\necho \"cmd=$RUNF_COMMAND\"\necho \"sub=$RUNF_SUBCOMMAND\"\necho \"exit=$RUNF_EXIT_CODE\"";
229 let filter = make_filter(RuntimeType::Shell, "env.sh", code, false);
230 let input = make_input("ignored");
231 let result = filter.filter(&input).unwrap();
232 assert!(result.text.contains("level=full"));
233 assert!(result.text.contains("cmd=test"));
234 assert!(result.text.contains("sub=sub"));
235 assert!(result.text.contains("exit=0"));
236 }
237
238 #[test]
239 fn empty_output_passthrough() {
240 let code = "#!/bin/sh\n# output nothing";
241 let filter = make_filter(RuntimeType::Shell, "empty.sh", code, false);
242 let input = make_input("some input");
243 let result = filter.filter(&input).unwrap();
244 assert!(result.passthrough);
245 assert!(result.text.is_empty());
246 }
247
248 #[test]
249 fn node_json_context_fields() {
250 let code = r#"
251const chunks = [];
252process.stdin.on("data", d => chunks.push(d));
253process.stdin.on("end", () => {
254 const input = JSON.parse(Buffer.concat(chunks).toString());
255 const out = `v=${input.version} cmd=${input.command} sub=${input.subcommand} lvl=${input.level} exit=${input.exit_code}`;
256 process.stdout.write(JSON.stringify({ output: out }));
257});
258"#;
259 let filter = make_filter(RuntimeType::Node, "ctx.js", code, true);
260 let input = make_input("raw data");
261 let result = filter.filter(&input).unwrap();
262 assert!(result.text.contains("v=1"));
263 assert!(result.text.contains("cmd=test"));
264 assert!(result.text.contains("sub=sub"));
265 assert!(result.text.contains("lvl=full"));
266 assert!(result.text.contains("exit=0"));
267 }
268}