1use async_trait::async_trait;
13use faucet_core::{FaucetError, Source, UnwrappedEnvelope, unwrap_envelope};
14use serde_json::Value;
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17use std::sync::{Arc, Mutex};
18
19const SEALED_LINE_PREFIX: &str = "RkNU";
23
24#[derive(Clone, Default)]
30pub struct DlqDecryptor {
31 #[cfg(feature = "encryption")]
32 inner: Option<Arc<faucet_core::CompiledEncryption>>,
33}
34
35impl std::fmt::Debug for DlqDecryptor {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 f.write_str("DlqDecryptor(..)")
38 }
39}
40
41enum LineDecode {
43 Plain,
45 #[cfg(feature = "encryption")]
47 Decrypted(String),
48 Undecryptable,
50}
51
52impl DlqDecryptor {
53 pub fn from_keys(keys: &[String]) -> Result<Self, FaucetError> {
57 if keys.is_empty() {
58 return Ok(Self::default());
59 }
60 #[cfg(feature = "encryption")]
61 {
62 let spec = faucet_core::EncryptionSpec {
63 key: keys[0].clone(),
64 previous_keys: keys[1..].to_vec(),
65 algorithm: Default::default(),
66 };
67 Ok(Self {
68 inner: Some(Arc::new(faucet_core::CompiledEncryption::compile(&spec)?)),
69 })
70 }
71 #[cfg(not(feature = "encryption"))]
72 Err(FaucetError::Config(
73 "--encryption-key requires a faucet build with the `encryption` feature \
74 (cargo install faucet-cli --features encryption)"
75 .into(),
76 ))
77 }
78
79 pub fn from_config_value(value: Option<&Value>) -> Result<Self, FaucetError> {
82 #[cfg_attr(not(feature = "encryption"), allow(unused_variables))]
84 let Some(value) = value else {
85 return Ok(Self::default());
86 };
87 #[cfg(feature = "encryption")]
88 {
89 let spec: faucet_core::EncryptionSpec = serde_json::from_value(value.clone())
90 .map_err(|e| FaucetError::Config(format!("dlq sink `encryption` block: {e}")))?;
91 Ok(Self {
92 inner: Some(Arc::new(faucet_core::CompiledEncryption::compile(&spec)?)),
93 })
94 }
95 #[cfg(not(feature = "encryption"))]
96 Err(FaucetError::Config(
97 "the config's dlq sink has an `encryption` block, but this faucet build has no \
98 `encryption` feature"
99 .into(),
100 ))
101 }
102
103 pub fn is_active(&self) -> bool {
105 #[cfg(feature = "encryption")]
106 {
107 self.inner.is_some()
108 }
109 #[cfg(not(feature = "encryption"))]
110 false
111 }
112
113 fn decode(&self, line: &str) -> LineDecode {
114 let trimmed = line.trim();
115 if !trimmed.starts_with(SEALED_LINE_PREFIX) {
116 return LineDecode::Plain;
117 }
118 #[cfg(feature = "encryption")]
119 if let Some(enc) = &self.inner {
120 use base64::Engine as _;
121 let Ok(sealed) = base64::engine::general_purpose::STANDARD.decode(trimmed) else {
122 return LineDecode::Plain;
125 };
126 if !faucet_core::encryption::is_encrypted(&sealed) {
127 return LineDecode::Plain;
128 }
129 return match enc.decrypt(&sealed) {
130 Ok(plain) => match String::from_utf8(plain) {
131 Ok(text) => LineDecode::Decrypted(text),
132 Err(_) => LineDecode::Undecryptable,
133 },
134 Err(_) => LineDecode::Undecryptable,
135 };
136 }
137 LineDecode::Undecryptable
140 }
141}
142
143#[derive(Clone)]
149pub struct SourceOverride(Arc<Mutex<Option<Box<dyn Source>>>>);
150
151impl SourceOverride {
152 pub fn new(source: Box<dyn Source>) -> Self {
154 Self(Arc::new(Mutex::new(Some(source))))
155 }
156
157 pub fn take(&self) -> Option<Box<dyn Source>> {
160 self.0.lock().ok().and_then(|mut g| g.take())
161 }
162}
163
164impl std::fmt::Debug for SourceOverride {
165 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 f.write_str("SourceOverride(..)")
167 }
168}
169
170#[derive(Debug, Clone, PartialEq)]
172pub enum LineOutcome {
173 Blank,
175 Malformed,
177 NonEnvelope,
180 Undecryptable,
183 Envelope(Box<UnwrappedEnvelope>),
185}
186
187pub fn classify_line(line: &str) -> LineOutcome {
191 classify_line_with(line, &DlqDecryptor::default())
192}
193
194pub fn classify_line_with(line: &str, dec: &DlqDecryptor) -> LineOutcome {
197 if line.trim().is_empty() {
198 return LineOutcome::Blank;
199 }
200 fn classify_text(text: &str) -> LineOutcome {
201 match serde_json::from_str::<Value>(text) {
202 Ok(value) => match unwrap_envelope(&value) {
203 Ok(env) => LineOutcome::Envelope(Box::new(env)),
204 Err(_) => LineOutcome::NonEnvelope,
205 },
206 Err(_) => LineOutcome::Malformed,
207 }
208 }
209 match dec.decode(line) {
210 LineDecode::Plain => classify_text(line),
211 #[cfg(feature = "encryption")]
212 LineDecode::Decrypted(plain) => classify_text(&plain),
213 LineDecode::Undecryptable => LineOutcome::Undecryptable,
214 }
215}
216
217#[derive(Debug, Default, Clone)]
219pub struct ScanResult {
220 pub envelopes: Vec<UnwrappedEnvelope>,
222 pub malformed: usize,
224 pub non_envelope: usize,
226 pub undecryptable: usize,
229 pub files_read: usize,
231}
232
233pub fn expand_location(location: &str) -> Result<Vec<PathBuf>, FaucetError> {
243 let has_glob = location.contains(['*', '?', '[']);
244 let mut files: Vec<PathBuf> = if has_glob {
245 glob::glob(location)
246 .map_err(|e| FaucetError::Config(format!("invalid DLQ glob '{location}': {e}")))?
247 .filter_map(Result::ok)
248 .filter(|p| p.is_file())
249 .collect()
250 } else {
251 let path = Path::new(location);
252 if path.is_dir() {
253 std::fs::read_dir(path)
254 .map_err(|e| FaucetError::Source(format!("reading DLQ dir '{location}': {e}")))?
255 .filter_map(Result::ok)
256 .map(|e| e.path())
257 .filter(|p| p.is_file() && p.extension().is_some_and(|x| x == "jsonl"))
258 .collect()
259 } else if path.is_file() {
260 vec![path.to_path_buf()]
261 } else {
262 Vec::new()
263 }
264 };
265 files.sort();
266 if files.is_empty() {
267 return Err(FaucetError::Source(format!(
268 "DLQ location '{location}' matched no files (expected a .jsonl file, a directory of \
269 .jsonl files, or a glob)"
270 )));
271 }
272 Ok(files)
273}
274
275pub fn scan_files(files: &[PathBuf], dec: &DlqDecryptor) -> Result<ScanResult, FaucetError> {
279 let mut out = ScanResult::default();
280 for file in files {
281 let text = std::fs::read_to_string(file).map_err(|e| {
282 FaucetError::Source(format!("reading DLQ file '{}': {e}", file.display()))
283 })?;
284 out.files_read += 1;
285 for line in text.lines() {
286 match classify_line_with(line, dec) {
287 LineOutcome::Blank => {}
288 LineOutcome::Malformed => out.malformed += 1,
289 LineOutcome::NonEnvelope => out.non_envelope += 1,
290 LineOutcome::Undecryptable => out.undecryptable += 1,
291 LineOutcome::Envelope(env) => out.envelopes.push(*env),
292 }
293 }
294 }
295 Ok(out)
296}
297
298pub fn reason_matches(env: &UnwrappedEnvelope, filter: Option<&str>) -> bool {
302 match filter {
303 None => true,
304 Some(want) => env.reason.as_deref() == Some(want),
305 }
306}
307
308pub struct DlqReaderSource {
315 files: Vec<PathBuf>,
316 reason: Option<String>,
317 dec: DlqDecryptor,
318}
319
320impl DlqReaderSource {
321 pub fn new(files: Vec<PathBuf>, reason: Option<String>, dec: DlqDecryptor) -> Self {
325 Self { files, reason, dec }
326 }
327}
328
329#[async_trait]
330impl Source for DlqReaderSource {
331 async fn fetch_with_context(
332 &self,
333 _context: &HashMap<String, Value>,
334 ) -> Result<Vec<Value>, FaucetError> {
335 let files = self.files.clone();
336 let reason = self.reason.clone();
337 let dec = self.dec.clone();
338 let scan = tokio::task::spawn_blocking(move || scan_files(&files, &dec))
340 .await
341 .map_err(|e| FaucetError::Source(format!("DLQ reader task panicked: {e}")))??;
342 Ok(scan
343 .envelopes
344 .into_iter()
345 .filter(|env| reason_matches(env, reason.as_deref()))
346 .map(|env| env.payload)
347 .collect())
348 }
349
350 fn connector_name(&self) -> &'static str {
351 "dlq-reader"
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358 use serde_json::json;
359 use std::io::Write;
360
361 fn envelope_line(reason: &str, payload: Value) -> String {
362 json!({
363 "error": { "kind": "Sink", "message": "boom" },
364 "reason": reason,
365 "payload": payload,
366 "ts_ms": 1,
367 "sink": "pg",
368 "pipeline": "etl",
369 "row": "",
370 "record_index": 0,
371 })
372 .to_string()
373 }
374
375 #[test]
376 fn classify_line_blank_is_ignored() {
377 assert_eq!(classify_line(""), LineOutcome::Blank);
378 assert_eq!(classify_line(" \t "), LineOutcome::Blank);
379 }
380
381 #[test]
382 fn classify_line_malformed_json() {
383 assert_eq!(classify_line("{not json"), LineOutcome::Malformed);
384 assert_eq!(classify_line("just text"), LineOutcome::Malformed);
385 }
386
387 #[test]
388 fn classify_line_valid_json_but_not_envelope() {
389 assert_eq!(classify_line(r#"{"a":1}"#), LineOutcome::NonEnvelope);
390 assert_eq!(classify_line("[1,2,3]"), LineOutcome::NonEnvelope);
391 }
392
393 #[test]
394 fn classify_line_parses_envelope() {
395 let line = envelope_line("quality", json!({"id": 7}));
396 match classify_line(&line) {
397 LineOutcome::Envelope(env) => {
398 assert_eq!(env.payload, json!({"id": 7}));
399 assert_eq!(env.reason.as_deref(), Some("quality"));
400 }
401 other => panic!("expected envelope, got {other:?}"),
402 }
403 }
404
405 #[test]
406 fn reason_matches_filter() {
407 let env = UnwrappedEnvelope {
408 payload: json!({}),
409 reason: Some("contract".into()),
410 error_kind: None,
411 error_message: None,
412 record_index: None,
413 pipeline: None,
414 row: None,
415 sink: None,
416 ts_ms: None,
417 };
418 assert!(reason_matches(&env, None));
419 assert!(reason_matches(&env, Some("contract")));
420 assert!(!reason_matches(&env, Some("quality")));
421 let legacy = UnwrappedEnvelope {
423 reason: None,
424 ..env
425 };
426 assert!(reason_matches(&legacy, None));
427 assert!(!reason_matches(&legacy, Some("quality")));
428 }
429
430 fn write_tmp(name: &str, body: &str) -> (tempfile::TempDir, PathBuf) {
431 let dir = tempfile::tempdir().unwrap();
432 let path = dir.path().join(name);
433 let mut f = std::fs::File::create(&path).unwrap();
434 f.write_all(body.as_bytes()).unwrap();
435 f.flush().unwrap();
436 (dir, path)
437 }
438
439 #[test]
440 fn scan_files_counts_skips_and_collects_envelopes() {
441 let body = format!(
442 "{}\n\n{}\nnot json\n{{\"a\":1}}\n",
443 envelope_line("quality", json!({"id": 1})),
444 envelope_line("contract", json!({"id": 2})),
445 );
446 let (_dir, path) = write_tmp("dlq.jsonl", &body);
447 let scan = scan_files(&[path], &DlqDecryptor::default()).unwrap();
448 assert_eq!(scan.envelopes.len(), 2);
449 assert_eq!(scan.malformed, 1);
450 assert_eq!(scan.non_envelope, 1);
451 assert_eq!(scan.files_read, 1);
452 }
453
454 #[test]
455 fn expand_location_glob_matches_multiple_files() {
456 let dir = tempfile::tempdir().unwrap();
457 for name in ["a.jsonl", "b.jsonl"] {
458 std::fs::write(dir.path().join(name), "\n").unwrap();
459 }
460 std::fs::write(dir.path().join("skip.txt"), "\n").unwrap();
461 let pattern = format!("{}/*.jsonl", dir.path().display());
462 let got = expand_location(&pattern).unwrap();
463 assert_eq!(got.len(), 2, "glob matches both .jsonl files, not the .txt");
464 assert!(expand_location(&format!("{}/*.none", dir.path().display())).is_err());
466 }
467
468 #[test]
469 fn expand_location_file_dir_and_missing() {
470 let (dir, path) = write_tmp("dlq.jsonl", "\n");
471 assert_eq!(
473 expand_location(path.to_str().unwrap()).unwrap(),
474 vec![path.clone()]
475 );
476 let got = expand_location(dir.path().to_str().unwrap()).unwrap();
478 assert_eq!(got, vec![path]);
479 assert!(expand_location(dir.path().join("nope.jsonl").to_str().unwrap()).is_err());
481 }
482
483 #[tokio::test]
484 async fn dlq_reader_source_yields_filtered_payloads() {
485 let body = format!(
486 "{}\n{}\n",
487 envelope_line("quality", json!({"id": 1})),
488 envelope_line("contract", json!({"id": 2})),
489 );
490 let (_dir, path) = write_tmp("dlq.jsonl", &body);
491 let src = DlqReaderSource::new(vec![path.clone()], None, DlqDecryptor::default());
493 let all = src.fetch_all().await.unwrap();
494 assert_eq!(all, vec![json!({"id": 1}), json!({"id": 2})]);
495 let src =
497 DlqReaderSource::new(vec![path], Some("contract".into()), DlqDecryptor::default());
498 let filtered = src.fetch_all().await.unwrap();
499 assert_eq!(filtered, vec![json!({"id": 2})]);
500 }
501
502 #[test]
503 fn source_override_takes_once() {
504 struct Dummy;
505 #[async_trait]
506 impl Source for Dummy {
507 async fn fetch_with_context(
508 &self,
509 _c: &HashMap<String, Value>,
510 ) -> Result<Vec<Value>, FaucetError> {
511 Ok(vec![])
512 }
513 }
514 let ov = SourceOverride::new(Box::new(Dummy));
515 assert!(ov.take().is_some());
516 assert!(ov.take().is_none());
517 }
518
519 #[cfg(feature = "encryption")]
520 mod sealed_lines {
521 use super::*;
522 use base64::Engine as _;
523
524 fn seal(key: &str, text: &str) -> String {
525 let enc = faucet_core::CompiledEncryption::compile(&faucet_core::EncryptionSpec {
526 key: key.into(),
527 previous_keys: vec![],
528 algorithm: Default::default(),
529 })
530 .unwrap();
531 base64::engine::general_purpose::STANDARD.encode(enc.encrypt(text.as_bytes()))
532 }
533
534 #[test]
535 fn sealed_envelope_classifies_with_the_right_key() {
536 let line = seal("k", &envelope_line("quality", serde_json::json!({"id": 1})));
537 let dec = DlqDecryptor::from_keys(&["k".to_string()]).unwrap();
538 assert!(matches!(
539 classify_line_with(&line, &dec),
540 LineOutcome::Envelope(_)
541 ));
542 let rotated = DlqDecryptor::from_keys(&["new".to_string(), "k".to_string()]).unwrap();
544 assert!(matches!(
545 classify_line_with(&line, &rotated),
546 LineOutcome::Envelope(_)
547 ));
548 }
549
550 #[test]
551 fn sealed_line_without_or_with_wrong_key_is_undecryptable_not_malformed() {
552 let line = seal("k", "{\"payload\": {}}");
553 assert_eq!(
554 classify_line_with(&line, &DlqDecryptor::default()),
555 LineOutcome::Undecryptable
556 );
557 let wrong = DlqDecryptor::from_keys(&["other".to_string()]).unwrap();
558 assert_eq!(
559 classify_line_with(&line, &wrong),
560 LineOutcome::Undecryptable
561 );
562 }
563
564 #[test]
565 fn plain_lines_pass_through_a_keyed_decryptor() {
566 let dec = DlqDecryptor::from_keys(&["k".to_string()]).unwrap();
567 assert!(matches!(
568 classify_line_with(&envelope_line("quality", serde_json::json!({"a": 1})), &dec),
569 LineOutcome::Envelope(_)
570 ));
571 assert_eq!(
572 classify_line_with("{not json", &dec),
573 LineOutcome::Malformed
574 );
575 assert_eq!(
578 classify_line_with("RkNU-not-really-sealed!!!", &dec),
579 LineOutcome::Malformed
580 );
581 }
582
583 #[test]
584 fn from_keys_empty_is_inert_and_from_config_value_none_is_inert() {
585 assert!(!DlqDecryptor::from_keys(&[]).unwrap().is_active());
586 assert!(!DlqDecryptor::from_config_value(None).unwrap().is_active());
587 let v = serde_json::json!({"key": "k"});
588 assert!(
589 DlqDecryptor::from_config_value(Some(&v))
590 .unwrap()
591 .is_active()
592 );
593 assert!(
594 DlqDecryptor::from_config_value(Some(&serde_json::json!({"nope": 1}))).is_err()
595 );
596 }
597 }
598}