Skip to main content

faucet_source_singer/
process.rs

1//! Tap subprocess runner: spawn, stream stdout as parsed messages over a
2//! **bounded** channel (backpressure), drain stderr into `tracing`, and always
3//! reap the child (SIGTERM → grace → SIGKILL on the paths we control;
4//! `kill_on_drop` is the backstop for an abrupt drop/cancel).
5
6use std::collections::VecDeque;
7use std::process::Stdio;
8use std::sync::{Arc, Mutex};
9use std::time::Duration;
10
11use faucet_core::{FaucetError, Value};
12use tokio::io::{AsyncBufReadExt, BufReader};
13use tokio::process::{Child, Command};
14use tokio::sync::mpsc;
15use tokio::task::JoinHandle;
16
17use crate::config::SingerSourceConfig;
18use crate::message::{SingerMessage, parse_line};
19
20/// Bounded channel capacity between the stdout reader task and the stream. This
21/// is the backpressure knob: the reader blocks on a full channel, which in turn
22/// blocks reading the tap's stdout, so memory stays bounded regardless of how
23/// fast the tap produces.
24const CHANNEL_CAPACITY: usize = 256;
25
26/// How many trailing stderr lines to retain for the failure message.
27const STDERR_TAIL_LINES: usize = 20;
28
29/// Grace period between SIGTERM and SIGKILL on a controlled shutdown.
30const SIGTERM_GRACE: Duration = Duration::from_secs(5);
31
32/// One item read from the tap's stdout: a parsed message, or a malformed line
33/// (the caller applies the [`MalformedPolicy`](crate::config::MalformedPolicy)).
34pub enum Line {
35    Parsed(SingerMessage),
36    Malformed(String),
37}
38
39/// Outcome of a single [`TapProcess::recv`] call.
40pub enum Recv {
41    /// A line was read.
42    Line(Line),
43    /// The tap closed stdout (end of stream).
44    Eof,
45    /// No line arrived within the configured idle timeout.
46    IdleTimeout,
47}
48
49/// Conservative secret redactor: scrubs the scalar string values found in the
50/// tap config out of any echoed text (stderr, command description). We can't
51/// know which values are secret, so every non-trivial string leaf is treated as
52/// sensitive.
53#[derive(Clone, Default)]
54pub struct Redactor {
55    secrets: Vec<String>,
56}
57
58impl Redactor {
59    /// Build a redactor from a tap-config object, collecting string leaves.
60    pub fn from_config(cfg: &Value) -> Self {
61        let mut secrets = Vec::new();
62        collect_strings(cfg, &mut secrets);
63        // Longest first so containing values are scrubbed before substrings.
64        secrets.sort_by_key(|s| std::cmp::Reverse(s.len()));
65        secrets.dedup();
66        Self { secrets }
67    }
68
69    /// Replace any known secret value in `s` with `***`.
70    pub fn redact(&self, s: &str) -> String {
71        let mut out = s.to_string();
72        for secret in &self.secrets {
73            if secret.len() >= 4 && out.contains(secret.as_str()) {
74                out = out.replace(secret.as_str(), "***");
75            }
76        }
77        out
78    }
79}
80
81fn collect_strings(v: &Value, out: &mut Vec<String>) {
82    match v {
83        Value::String(s) => out.push(s.clone()),
84        Value::Array(a) => a.iter().for_each(|x| collect_strings(x, out)),
85        Value::Object(o) => o.values().for_each(|x| collect_strings(x, out)),
86        _ => {}
87    }
88}
89
90/// A running tap process and its stdout message channel.
91pub struct TapProcess {
92    child: Child,
93    pid: Option<u32>,
94    rx: mpsc::Receiver<Line>,
95    stderr_tail: Arc<Mutex<VecDeque<String>>>,
96    // Temp files kept alive for the process lifetime; deleted on drop.
97    _temp_files: Vec<tempfile::NamedTempFile>,
98    _reader: JoinHandle<()>,
99    _stderr: JoinHandle<()>,
100}
101
102impl TapProcess {
103    /// Spawn the tap with `--config` (and optionally `--catalog` / `--state`).
104    ///
105    /// `start_state` is the resume bookmark loaded from the state store (the
106    /// STATE `value` blob), materialized to a `state.json` and passed as
107    /// `--state` when present.
108    pub fn spawn(
109        cfg: &SingerSourceConfig,
110        start_state: Option<&Value>,
111    ) -> Result<Self, FaucetError> {
112        let redactor = Redactor::from_config(&cfg.tap_config);
113        let mut temp_files = Vec::new();
114
115        let config_path = write_temp("config", &cfg.tap_config)?;
116        let mut command = Command::new(&cfg.executable);
117        command.arg("--config").arg(config_path.path());
118        temp_files.push(config_path);
119
120        if let Some(catalog) = &cfg.catalog {
121            let catalog_path = write_temp("catalog", catalog)?;
122            command.arg("--catalog").arg(catalog_path.path());
123            temp_files.push(catalog_path);
124        }
125
126        if let Some(state) = start_state {
127            let state_path = write_temp("state", state)?;
128            command.arg("--state").arg(state_path.path());
129            temp_files.push(state_path);
130        }
131
132        command.args(&cfg.args);
133        command
134            .stdin(Stdio::null())
135            .stdout(Stdio::piped())
136            .stderr(Stdio::piped())
137            .kill_on_drop(true);
138
139        tracing::debug!(
140            executable = %cfg.executable,
141            stream = %cfg.stream,
142            "spawning singer tap"
143        );
144
145        let mut child = command.spawn().map_err(|e| {
146            FaucetError::Source(format!(
147                "failed to spawn tap '{}': {}",
148                redactor.redact(&cfg.executable),
149                e
150            ))
151        })?;
152        let pid = child.id();
153
154        let stdout = child
155            .stdout
156            .take()
157            .ok_or_else(|| FaucetError::Source("tap stdout not captured".into()))?;
158        let stderr = child
159            .stderr
160            .take()
161            .ok_or_else(|| FaucetError::Source("tap stderr not captured".into()))?;
162
163        let (tx, rx) = mpsc::channel::<Line>(CHANNEL_CAPACITY);
164        let policy_reader = {
165            let tx = tx.clone();
166            tokio::spawn(async move {
167                let mut lines = BufReader::new(stdout).lines();
168                loop {
169                    match lines.next_line().await {
170                        Ok(Some(line)) => {
171                            let item = match parse_line(&line) {
172                                Ok(msg) => Line::Parsed(msg),
173                                Err(reason) => Line::Malformed(reason),
174                            };
175                            // `send().await` blocks on a full channel — this is
176                            // the backpressure that bounds memory.
177                            if tx.send(item).await.is_err() {
178                                break; // consumer gone
179                            }
180                        }
181                        Ok(None) => break, // EOF
182                        Err(e) => {
183                            let _ = tx
184                                .send(Line::Malformed(format!("stdout read error: {e}")))
185                                .await;
186                            break;
187                        }
188                    }
189                }
190            })
191        };
192        drop(tx);
193
194        let stderr_tail = Arc::new(Mutex::new(VecDeque::with_capacity(STDERR_TAIL_LINES)));
195        let stderr_task = {
196            let stderr_tail = Arc::clone(&stderr_tail);
197            let redactor = redactor.clone();
198            tokio::spawn(async move {
199                let mut lines = BufReader::new(stderr).lines();
200                while let Ok(Some(line)) = lines.next_line().await {
201                    let redacted = redactor.redact(&line);
202                    tracing::debug!(target: "faucet_source_singer::tap", "{redacted}");
203                    let mut tail = stderr_tail.lock().unwrap();
204                    if tail.len() == STDERR_TAIL_LINES {
205                        tail.pop_front();
206                    }
207                    tail.push_back(redacted);
208                }
209            })
210        };
211
212        Ok(Self {
213            child,
214            pid,
215            rx,
216            stderr_tail,
217            _temp_files: temp_files,
218            _reader: policy_reader,
219            _stderr: stderr_task,
220        })
221    }
222
223    /// Receive the next stdout line, honoring the optional idle timeout.
224    pub async fn recv(&mut self, idle_timeout: Option<Duration>) -> Recv {
225        match idle_timeout {
226            Some(timeout) => match tokio::time::timeout(timeout, self.rx.recv()).await {
227                Ok(Some(line)) => Recv::Line(line),
228                Ok(None) => Recv::Eof,
229                Err(_) => Recv::IdleTimeout,
230            },
231            None => match self.rx.recv().await {
232                Some(line) => Recv::Line(line),
233                None => Recv::Eof,
234            },
235        }
236    }
237
238    /// The trailing stderr lines (already redacted), newest last.
239    pub fn stderr_tail(&self) -> String {
240        self.stderr_tail
241            .lock()
242            .unwrap()
243            .iter()
244            .cloned()
245            .collect::<Vec<_>>()
246            .join("\n")
247    }
248
249    /// Gracefully stop the tap (SIGTERM → grace → SIGKILL) and reap it, then
250    /// return an error if it exited non-zero. Idempotent-ish: safe to call once
251    /// on the normal EOF path or the idle-timeout path.
252    pub async fn shutdown_and_check(&mut self) -> Result<(), FaucetError> {
253        let status = self.terminate().await;
254        match status {
255            Ok(status) if status.success() => Ok(()),
256            Ok(status) => Err(FaucetError::Source(format!(
257                "tap exited with status {}; last stderr:\n{}",
258                status,
259                self.stderr_tail()
260            ))),
261            Err(e) => Err(FaucetError::Source(format!(
262                "failed to reap tap: {e}; last stderr:\n{}",
263                self.stderr_tail()
264            ))),
265        }
266    }
267
268    /// Terminate and reap the tap, ignoring its exit status. Used on the error
269    /// paths (idle timeout, malformed-fail) where the failure reason is already
270    /// known and the exit code is irrelevant.
271    pub async fn shutdown(&mut self) {
272        let _ = self.terminate().await;
273    }
274
275    /// SIGTERM → grace → SIGKILL, then reap. Returns the final exit status.
276    async fn terminate(&mut self) -> std::io::Result<std::process::ExitStatus> {
277        if let Ok(Some(status)) = self.child.try_wait() {
278            return Ok(status); // already exited
279        }
280        #[cfg(unix)]
281        if let Some(pid) = self.pid {
282            // SAFETY: pid is this child's pid; SIGTERM is a defined signal.
283            unsafe {
284                libc::kill(pid as i32, libc::SIGTERM);
285            }
286        }
287        #[cfg(not(unix))]
288        let _ = self.pid; // silence unused on non-unix
289        match tokio::time::timeout(SIGTERM_GRACE, self.child.wait()).await {
290            Ok(status) => status,
291            Err(_) => {
292                tracing::warn!("tap did not exit within grace period; sending SIGKILL");
293                let _ = self.child.start_kill();
294                self.child.wait().await
295            }
296        }
297    }
298}
299
300/// Write `value` as JSON to a private (0600) temp file.
301pub(crate) fn write_temp(
302    kind: &str,
303    value: &Value,
304) -> Result<tempfile::NamedTempFile, FaucetError> {
305    use std::io::Write;
306    let mut file = tempfile::Builder::new()
307        .prefix(&format!("faucet-singer-{kind}-"))
308        .suffix(".json")
309        .tempfile()
310        .map_err(|e| FaucetError::Source(format!("failed to create {kind} temp file: {e}")))?;
311    // `tempfile` creates the file 0600 via mkstemp; set it explicitly to be sure.
312    #[cfg(unix)]
313    {
314        use std::os::unix::fs::PermissionsExt;
315        file.as_file()
316            .set_permissions(std::fs::Permissions::from_mode(0o600))
317            .map_err(|e| FaucetError::Source(format!("failed to chmod {kind} temp file: {e}")))?;
318    }
319    let bytes = serde_json::to_vec(value)
320        .map_err(|e| FaucetError::Source(format!("failed to serialize {kind}: {e}")))?;
321    file.write_all(&bytes)
322        .and_then(|_| file.flush())
323        .map_err(|e| FaucetError::Source(format!("failed to write {kind} temp file: {e}")))?;
324    Ok(file)
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use serde_json::json;
331
332    #[test]
333    fn redactor_scrubs_config_string_values() {
334        let cfg = json!({"token": "supersecrettoken", "n": 5, "nested": {"pw": "hunter2pass"}});
335        let r = Redactor::from_config(&cfg);
336        let line = "auth with supersecrettoken and hunter2pass ok";
337        let out = r.redact(line);
338        assert!(!out.contains("supersecrettoken"));
339        assert!(!out.contains("hunter2pass"));
340        assert!(out.contains("***"));
341    }
342
343    #[test]
344    fn redactor_ignores_short_values() {
345        let cfg = json!({"x": "ab"});
346        let r = Redactor::from_config(&cfg);
347        assert_eq!(r.redact("value ab here"), "value ab here");
348    }
349
350    #[test]
351    fn write_temp_is_private_and_valid_json() {
352        let f = write_temp("config", &json!({"a": 1})).unwrap();
353        let contents = std::fs::read_to_string(f.path()).unwrap();
354        assert_eq!(contents, r#"{"a":1}"#);
355        #[cfg(unix)]
356        {
357            use std::os::unix::fs::PermissionsExt;
358            let mode = std::fs::metadata(f.path()).unwrap().permissions().mode();
359            assert_eq!(mode & 0o777, 0o600);
360        }
361    }
362}