Skip to main content

lowfat_runner/
process.rs

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
8/// Runs a shell plugin as an external process via stdin/stdout.
9pub 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        // Threaded write: a filter may exit early or fill its stdout pipe
41        // before stdin drains — a blocking write EPIPEs or deadlocks here
42        // (issue #9; same pattern as lowfat-core's run_filter_child).
43        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}