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 if let Some(mut stdin) = child.stdin.take() {
41 let _ = stdin.write_all(input.raw.as_bytes());
42 }
43
44 let output = child.wait_with_output()?;
45 let text = String::from_utf8_lossy(&output.stdout).to_string();
46
47 Ok(FilterOutput {
48 passthrough: text.is_empty(),
49 text,
50 })
51 }
52}
53
54#[cfg(test)]
55mod tests {
56 use super::*;
57 use lowfat_core::level::Level;
58 use std::io::Write;
59
60 fn make_input(raw: &str) -> FilterInput {
61 FilterInput {
62 raw: raw.to_string(),
63 command: "test".to_string(),
64 subcommand: "sub".to_string(),
65 args: vec!["arg1".to_string()],
66 level: Level::Full,
67 head_limit: 40,
68 exit_code: 0,
69 }
70 }
71
72 fn make_filter(entry: &str, code: &str) -> ProcessFilter {
73 let dir = std::env::temp_dir().join(format!("lowfat-test-{}-{}", entry, std::process::id()));
74 std::fs::create_dir_all(&dir).unwrap();
75 let path = dir.join(entry);
76 let mut f = std::fs::File::create(&path).unwrap();
77 f.write_all(code.as_bytes()).unwrap();
78
79 ProcessFilter {
80 info: PluginInfo {
81 name: "test-plugin".into(),
82 version: "0.1.0".into(),
83 commands: vec!["test".into()],
84 subcommands: vec![],
85 },
86 entry: path,
87 base_dir: dir,
88 }
89 }
90
91 #[test]
92 fn shell_filter() {
93 let filter = make_filter("filter.sh", "#!/bin/sh\ngrep -v '^warning:'");
94 let input = make_input("ok line\nwarning: skip\nanother line");
95 let result = filter.filter(&input).unwrap();
96 assert_eq!(result.text.trim(), "ok line\nanother line");
97 assert!(!result.passthrough);
98 }
99
100 #[test]
101 fn shell_env_vars() {
102 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\"";
103 let filter = make_filter("env.sh", code);
104 let input = make_input("ignored");
105 let result = filter.filter(&input).unwrap();
106 assert!(result.text.contains("level=full"));
107 assert!(result.text.contains("cmd=test"));
108 assert!(result.text.contains("sub=sub"));
109 assert!(result.text.contains("args=arg1"));
110 assert!(result.text.contains("exit=0"));
111 }
112
113 #[test]
114 fn empty_output_passthrough() {
115 let filter = make_filter("empty.sh", "#!/bin/sh\n# output nothing");
116 let input = make_input("some input");
117 let result = filter.filter(&input).unwrap();
118 assert!(result.passthrough);
119 assert!(result.text.is_empty());
120 }
121}