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