faucet_source_singer/
process.rs1use 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
20const CHANNEL_CAPACITY: usize = 256;
25
26const STDERR_TAIL_LINES: usize = 20;
28
29const SIGTERM_GRACE: Duration = Duration::from_secs(5);
31
32pub enum Line {
35 Parsed(SingerMessage),
36 Malformed(String),
37}
38
39pub enum Recv {
41 Line(Line),
43 Eof,
45 IdleTimeout,
47}
48
49#[derive(Clone, Default)]
54pub struct Redactor {
55 secrets: Vec<String>,
56}
57
58impl Redactor {
59 pub fn from_config(cfg: &Value) -> Self {
61 let mut secrets = Vec::new();
62 collect_strings(cfg, &mut secrets);
63 secrets.sort_by_key(|s| std::cmp::Reverse(s.len()));
65 secrets.dedup();
66 Self { secrets }
67 }
68
69 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
90pub struct TapProcess {
92 child: Child,
93 pid: Option<u32>,
94 rx: mpsc::Receiver<Line>,
95 stderr_tail: Arc<Mutex<VecDeque<String>>>,
96 _temp_files: Vec<tempfile::NamedTempFile>,
98 _reader: JoinHandle<()>,
99 _stderr: JoinHandle<()>,
100}
101
102impl TapProcess {
103 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 if tx.send(item).await.is_err() {
178 break; }
180 }
181 Ok(None) => break, 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 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 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 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 pub async fn shutdown(&mut self) {
272 let _ = self.terminate().await;
273 }
274
275 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); }
280 #[cfg(unix)]
281 if let Some(pid) = self.pid {
282 unsafe {
284 libc::kill(pid as i32, libc::SIGTERM);
285 }
286 }
287 #[cfg(not(unix))]
288 let _ = self.pid; 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
300pub(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 #[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}