1use anyhow::{Context, Result};
2use lowfat_plugin::plugin::{FilterInput, FilterOutput, FilterPlugin, PluginInfo};
3use lowfat_plugin::security;
4use std::io::Write;
5use std::path::PathBuf;
6use std::process::{Command, Stdio};
7
8pub struct ProcessFilter {
10 pub info: PluginInfo,
11 pub entry: PathBuf,
12 pub base_dir: PathBuf,
13}
14
15impl FilterPlugin for ProcessFilter {
16 fn info(&self) -> PluginInfo {
17 self.info.clone()
18 }
19
20 fn filter(&self, input: &FilterInput) -> Result<FilterOutput> {
21 let entry = self.entry.to_string_lossy().to_string();
22 let safe_env = security::sanitized_env();
23
24 let mut child = Command::new("sh")
25 .arg(&entry)
26 .current_dir(&self.base_dir)
27 .stdin(Stdio::piped())
28 .stdout(Stdio::piped())
29 .stderr(Stdio::piped())
30 .env_clear()
31 .envs(safe_env)
32 .env("LOWFAT_LEVEL", input.level.to_string())
33 .env("LOWFAT_COMMAND", &input.command)
34 .env("LOWFAT_SUBCOMMAND", &input.subcommand)
35 .env("LOWFAT_ARGS", input.args.join(" "))
36 .env("LOWFAT_EXIT_CODE", input.exit_code.to_string())
37 .spawn()
38 .with_context(|| format!("failed to spawn plugin: sh {entry}"))?;
39
40 let writer = child.stdin.take().map(|mut stdin| {
44 let data = input.raw.clone();
45 std::thread::spawn(move || match stdin.write_all(data.as_bytes()) {
46 Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
47 r => r,
48 })
49 });
50
51 let output = child.wait_with_output()?;
52 if let Some(w) = writer {
53 w.join()
54 .map_err(|_| anyhow::anyhow!("plugin stdin writer panicked"))?
55 .with_context(|| format!("writing to plugin stdin: {entry}"))?;
56 }
57 let text = String::from_utf8_lossy(&output.stdout).to_string();
58
59 Ok(FilterOutput {
60 passthrough: text.is_empty(),
61 text,
62 })
63 }
64}
65
66#[cfg(test)]
67mod tests {
68 use super::*;
69 use lowfat_core::level::Level;
70 use std::io::Write;
71
72 fn make_input(raw: &str) -> FilterInput {
73 FilterInput {
74 raw: raw.to_string(),
75 command: "test".to_string(),
76 subcommand: "sub".to_string(),
77 args: vec!["arg1".to_string()],
78 level: Level::Full,
79 head_limit: 40,
80 exit_code: 0,
81 }
82 }
83
84 fn make_filter(entry: &str, code: &str) -> ProcessFilter {
85 let dir = std::env::temp_dir().join(format!("lowfat-test-{}-{}", entry, std::process::id()));
86 std::fs::create_dir_all(&dir).unwrap();
87 let path = dir.join(entry);
88 let mut f = std::fs::File::create(&path).unwrap();
89 f.write_all(code.as_bytes()).unwrap();
90
91 ProcessFilter {
92 info: PluginInfo {
93 name: "test-plugin".into(),
94 version: "0.1.0".into(),
95 commands: vec!["test".into()],
96 subcommands: vec![],
97 },
98 entry: path,
99 base_dir: dir,
100 }
101 }
102
103 #[test]
104 fn shell_filter() {
105 let filter = make_filter("filter.sh", "#!/bin/sh\ngrep -v '^warning:'");
106 let input = make_input("ok line\nwarning: skip\nanother line");
107 let result = filter.filter(&input).unwrap();
108 assert_eq!(result.text.trim(), "ok line\nanother line");
109 assert!(!result.passthrough);
110 }
111
112 #[test]
113 fn shell_env_vars() {
114 let code = "#!/bin/sh\necho \"level=$LOWFAT_LEVEL\"\necho \"cmd=$LOWFAT_COMMAND\"\necho \"sub=$LOWFAT_SUBCOMMAND\"\necho \"args=$LOWFAT_ARGS\"\necho \"exit=$LOWFAT_EXIT_CODE\"";
115 let filter = make_filter("env.sh", code);
116 let input = make_input("ignored");
117 let result = filter.filter(&input).unwrap();
118 assert!(result.text.contains("level=full"));
119 assert!(result.text.contains("cmd=test"));
120 assert!(result.text.contains("sub=sub"));
121 assert!(result.text.contains("args=arg1"));
122 assert!(result.text.contains("exit=0"));
123 }
124
125 #[test]
126 fn empty_output_passthrough() {
127 let filter = make_filter("empty.sh", "#!/bin/sh\n# output nothing");
128 let input = make_input("some input");
129 let result = filter.filter(&input).unwrap();
130 assert!(result.passthrough);
131 assert!(result.text.is_empty());
132 }
133}