1use crate::{
2 daemonise, enforce_singleton, into_recoverable, telemetry_config, EnvSetting, Result,
3 TelemetryError, WatcherResult,
4};
5
6use base64::{engine::general_purpose::STANDARD, DecodeError, Engine};
7use chrono::NaiveDateTime;
8use influxdb_line_protocol::LineProtocolBuilder;
9use nix::{
10 fcntl::{Flock, FlockArg},
11 sys::signal::{kill, Signal::SIGKILL},
12 unistd::{getpid, Pid},
13};
14use regex::{Captures, Regex};
15use reqwest::blocking::{Client, Request, RequestBuilder};
16use std::fs::ReadDir;
17use std::{
18 collections::HashMap,
19 env::var,
20 fs::{read_dir, remove_file, File, OpenOptions},
21 io::{BufRead, BufReader, Read, Write},
22 path::{Path, PathBuf},
23 process::exit,
24 sync::{
25 atomic::{AtomicBool, AtomicI32, Ordering},
26 LazyLock,
27 },
28 thread::sleep,
29 time::Duration,
30};
31
32const PROCESS_NAME: &str = "telemetry-file-watcher";
37const INFLUXDB_ORG: &str = "Dev%20Team";
38const INFLUXDB_BUCKET: &str = "telemetry";
39const INFLUXDB_URL: &str = "https://us-east-1-1.aws.cloud2.influxdata.com";
40const INFLUXDB_TOKEN: &str =
41 "l7Sho-XGD9BfGLQrKWwoBub-hC0gqJ5xRS2zz4pkjb6cGyBJZUQpw7qpwTfXTFGLXufCh7ZmQWv4bUtAsT60Ag==";
42
43#[derive(Debug, Clone)]
45struct FileWatcherConfig {
46 lockfile: PathBuf,
48 logfile: PathBuf,
50 influxdb_token: String,
52 influxdb_url: String,
54 poll_interval: Duration,
56}
57
58fn config() -> Result<&'static FileWatcherConfig> {
62 static FILEWATCHER_CONFIG: LazyLock<Result<FileWatcherConfig>> = LazyLock::new(|| {
63 let get_env = |key, default| EnvSetting::new(key, default).get();
64
65 let poll_interval = get_env("FILEWATCHER_POLL_INTERVAL", "3600").parse()?;
67
68 let base_url = get_env("INFLUXDB_URL", INFLUXDB_URL);
70 let org = get_env("INFLUXDB_ORG", INFLUXDB_ORG);
71 let bucket = get_env("INFLUXDB_BUCKET", INFLUXDB_BUCKET);
72 let influxdb_url =
73 format!("{base_url}/api/v2/write?org={org}&bucket={bucket}&precision=ns");
74
75 Ok(FileWatcherConfig {
76 poll_interval: Duration::from_secs(poll_interval),
77 influxdb_token: get_env("INFLUXDB_TOKEN", INFLUXDB_TOKEN),
78 influxdb_url,
79 lockfile: Path::new(&telemetry_config()?.fuelup_tmp)
80 .join(format!("{PROCESS_NAME}.lock")),
81 logfile: Path::new(&telemetry_config()?.fuelup_log).join(format!("{PROCESS_NAME}.log")),
82 })
83 });
84
85 FILEWATCHER_CONFIG
86 .as_ref()
87 .map_err(|e| TelemetryError::InvalidConfig(e.to_string()))
88}
89
90#[derive(Default)]
96pub struct FileWatcher {
97 client: Option<Client>,
99 request: Option<Request>,
101}
102
103static STARTED: AtomicBool = AtomicBool::new(false);
105
106pub static PID: AtomicI32 = AtomicI32::new(0);
108
109static TELEMETRY_EVENT_REGEX: LazyLock<Result<Regex>> = LazyLock::new(|| {
111 Ok(Regex::new(
112 r"(?x)
113 ^ \s*
114 (?P<timestamp>[^\s]+) \s+
115 (?P<level>(TRACE|DEBUG|INFO|WARN|ERROR)) \s+
116 (?P<triple>.*?):(?P<os>.*?):(?P<os_version>.*?) \s+
117 (?P<crate_pkg_name>[^\s]+):(?P<crate_pkg_version>[^\s]+):(?P<file>[^\s]+) \s+
118 (?P<trace_id>[^\s]+) \s+
119 (?P<payload>.*) \s*
120 $
121 ",
122 )?)
123});
124
125static TELEMETRY_PAYLOAD_REGEX: LazyLock<Result<Regex>> = LazyLock::new(|| {
134 Ok(Regex::new(
135 r"(?x)
136 (?P<span>[^\s\:][^\:]*?[^\s\:]*?) :
137 | (?:^|\s+) (?P<message_only>[^\=]+) \s* $
138 | (?:^|\s+) (?P<message>[^\=]+) \s+
139 | (?P<left>[^\s\=]+) = (?P<right>[^\=]+) (?:\s+|$)
140 ",
141 )?)
142});
143
144impl FileWatcher {
145 pub fn new() -> Self {
155 Self::default()
156 }
157
158 pub fn start(&mut self) -> WatcherResult<()> {
164 self.start_with_helpers(&DefaultStartHelpers)
165 }
166
167 fn start_with_helpers(&mut self, helpers: &impl StartHelpers) -> WatcherResult<()> {
168 if var("FUELUP_NO_TELEMETRY").is_ok() {
169 return Ok(());
171 }
172
173 let logfile = &config().map_err(into_recoverable)?.logfile;
174
175 if STARTED.load(Ordering::Relaxed) {
176 return Ok(());
177 } else {
178 STARTED.store(true, Ordering::Relaxed);
179 }
180
181 match helpers.daemonise(logfile) {
182 Ok(Some(pid)) => {
183 PID.store(pid.as_raw(), Ordering::Relaxed);
185 return Ok(());
186 }
187 Err(e) => {
188 STARTED.store(false, Ordering::Relaxed);
190 PID.store(0, Ordering::Relaxed);
191 return Err(e);
192 }
193 Ok(None) => {
194 PID.store(getpid().as_raw(), Ordering::Relaxed);
196 }
197 }
198
199 self.client = Some(Client::new());
206 self.request = Some(helpers.build_request(self)?);
207
208 loop {
209 let _lock = helpers.enforce_singleton(&config()?.lockfile)?;
212
213 let directory_empty = helpers.poll_directory(self)?;
215
216 if directory_empty {
217 helpers.exit(0);
218 }
219
220 helpers.sleep(config()?.poll_interval);
222 }
223 }
224
225 pub fn kill() -> Result<bool> {
227 let pid = PID.load(Ordering::Relaxed);
228
229 if pid == 0 {
230 return Ok(false);
231 }
232
233 kill(Pid::from_raw(pid), SIGKILL)?;
234 PID.store(0, Ordering::Relaxed);
235 Ok(true)
236 }
237
238 fn poll_directory(&self) -> Result<bool> {
240 self.poll_directory_with_helpers(&mut DefaultPollDirectoryHelpers)
241 }
242
243 fn poll_directory_with_helpers(&self, helpers: &mut impl PollDirectoryHelpers) -> Result<bool> {
244 for file in helpers.find_telemetry_files(false)? {
245 let locked_file = helpers
247 .flock(
248 OpenOptions::new().read(true).open(&file)?,
249 FlockArg::LockExclusiveNonblock,
250 )
251 .map_err(|(_, e)| e)?;
252
253 let mut body = Vec::new();
255 let lines = helpers.buffered_reader(locked_file.try_clone()?)?;
256
257 for base64_line in lines {
258 let decoded_line = helpers.base64_decode(&base64_line)?;
260 let line = helpers.string_from_utf8(decoded_line)?;
261
262 let (event, spans_long, spans_short, fields) = helpers.parse_event(&line)?;
263 let line_protocol =
264 helpers.build_line_protocol(&event, &spans_long, &spans_short, &fields);
265
266 body.push(helpers.string_from_utf8(line_protocol)?);
267 }
268
269 if body.is_empty() {
270 continue;
271 }
272
273 let request = helpers.build_request(self, &body)?;
279
280 if helpers.send_request(request)? {
281 helpers.remove_file(&file)?;
283 }
284 }
285
286 Ok(helpers.find_telemetry_files(true)?.is_empty())
290 }
291
292 pub fn log_error(message: &str) -> Result<()> {
295 let mut file = OpenOptions::new()
296 .create(true)
297 .append(true)
298 .open(&config()?.logfile)?;
299
300 Ok(writeln!(file, "{message}")?)
301 }
302}
303
304trait StartHelpers {
305 fn daemonise(&self, logfile: &PathBuf) -> WatcherResult<Option<Pid>> {
306 daemonise(logfile)
307 }
308
309 fn build_request(&self, file_watcher: &FileWatcher) -> Result<Request> {
310 Ok(file_watcher
311 .client
312 .as_ref()
313 .ok_or(TelemetryError::InvalidClient)?
314 .post(&config()?.influxdb_url)
315 .header("Content-Type", "text/plain; charset=utf-8")
316 .header("Accept", "application/json")
317 .header(
318 "Authorization",
319 format!("Token {}", config()?.influxdb_token.clone()),
320 )
321 .build()?)
322 }
323
324 fn enforce_singleton(&self, lockfile_path: &Path) -> Result<Flock<File>> {
325 enforce_singleton(lockfile_path)
326 }
327
328 fn poll_directory(&self, file_watcher: &FileWatcher) -> Result<bool> {
329 file_watcher.poll_directory()
330 }
331
332 fn exit(&self, code: i32) {
333 exit(code)
334 }
335
336 fn sleep(&self, duration: Duration) {
337 sleep(duration)
338 }
339}
340
341struct DefaultStartHelpers;
342impl StartHelpers for DefaultStartHelpers {}
343
344trait PollDirectoryHelpers {
345 fn find_telemetry_files(&mut self, ignore_age: bool) -> Result<Vec<PathBuf>> {
346 find_telemetry_files(ignore_age)
347 }
348
349 fn flock(
350 &self,
351 file: File,
352 flags: FlockArg,
353 ) -> std::result::Result<Flock<File>, (File, nix::errno::Errno)> {
354 Flock::lock(file, flags)
355 }
356
357 fn buffered_reader<R: Read>(
358 &self,
359 file: R,
360 ) -> std::result::Result<Vec<String>, std::io::Error> {
361 BufReader::new(file)
362 .lines()
363 .collect::<std::result::Result<Vec<_>, _>>()
364 }
365
366 fn base64_decode(&self, line: &str) -> std::result::Result<Vec<u8>, DecodeError> {
367 STANDARD.decode(line.as_bytes())
368 }
369
370 fn string_from_utf8(
371 &mut self,
372 bytes: Vec<u8>,
373 ) -> std::result::Result<String, std::string::FromUtf8Error> {
374 String::from_utf8(bytes)
375 }
376
377 #[allow(clippy::type_complexity)]
378 fn parse_event<'a>(
379 &self,
380 line: &'a str,
381 ) -> Result<(
382 Captures<'a>,
383 Vec<String>,
384 Vec<String>,
385 HashMap<&'a str, String>,
386 )> {
387 let event = TELEMETRY_EVENT_REGEX
388 .as_ref()?
389 .captures(line)
390 .ok_or_else(|| TelemetryError::InvalidTracingEvent(line.to_string()))?;
391
392 let payload = event
393 .name("payload")
394 .ok_or_else(|| TelemetryError::InvalidTracingPayload(line.to_string()))?;
395
396 let (mut spans_long, mut spans_short, mut fields) = (vec![], vec![], HashMap::new());
397
398 for capture in TELEMETRY_PAYLOAD_REGEX
399 .as_ref()?
400 .captures_iter(payload.into())
401 {
402 if let Some(span) = capture.name("span") {
403 let span_str = span.as_str();
404 spans_long.push(span_str.to_string());
405 spans_short.push(span_str.split('{').next().unwrap_or(span_str).to_string());
406 } else if let Some(message) = capture.name("message") {
407 fields.insert("message", message.as_str().to_string());
408 } else if let Some(message_only) = capture.name("message_only") {
409 fields.insert("message", message_only.as_str().to_string());
410 } else if let (Some(left), Some(right)) = (capture.name("left"), capture.name("right"))
411 {
412 fields.insert(left.as_str(), right.as_str().to_string());
413 }
414 }
415
416 Ok((event, spans_long, spans_short, fields))
417 }
418
419 fn build_line_protocol(
420 &self,
421 event: &Captures<'_>,
422 spans_long: &[String],
423 spans_short: &[String],
424 fields: &HashMap<&str, String>,
425 ) -> Vec<u8> {
426 let mut line_protocol_builder = LineProtocolBuilder::new()
427 .measurement(event.name("crate_pkg_name").map_or("", |m| m.as_str()))
428 .tag(
429 "version",
430 event.name("crate_pkg_version").map_or("", |m| m.as_str()),
431 )
432 .tag("file", event.name("file").map_or("", |m| m.as_str()))
433 .tag("spans", &spans_short.join(":"))
434 .tag(
435 "trace_id",
436 event.name("trace_id").map_or("", |m| m.as_str()),
437 )
438 .field("spans_long", spans_long.join(":").as_str())
439 .field("triple", event.name("triple").map_or("", |m| m.as_str()));
440
441 for (key, value) in fields {
442 line_protocol_builder = line_protocol_builder.field(key, value.as_str());
443 }
444
445 let line_protocol_builder = line_protocol_builder.timestamp(
449 event
450 .name("timestamp")
451 .and_then(|m| {
452 NaiveDateTime::parse_from_str(m.as_str(), "%Y-%m-%dT%H:%M:%S%.9f%Z")
456 .ok()
457 .map(|dt| dt.and_utc().timestamp_nanos_opt())
458 })
459 .flatten()
460 .unwrap_or(0),
461 );
462
463 line_protocol_builder.close_line().build()
464 }
465
466 fn build_request(&self, file_watcher: &FileWatcher, body: &[String]) -> Result<RequestBuilder> {
467 Ok(RequestBuilder::from_parts(
468 file_watcher
469 .client
470 .as_ref()
471 .ok_or(TelemetryError::InvalidClient)?
472 .clone(),
473 file_watcher
474 .request
475 .as_ref()
476 .ok_or(TelemetryError::InvalidRequest)?
477 .try_clone()
478 .ok_or(TelemetryError::ReqwestCloneFailed)?,
479 )
480 .body(body.join("\n")))
481 }
482
483 fn send_request(&self, request: RequestBuilder) -> Result<bool> {
484 Ok(request.send().is_ok_and(|r| r.status().is_success()))
485 }
486
487 fn remove_file(&self, file: &Path) -> std::io::Result<()> {
488 remove_file(file)
489 }
490}
491
492struct DefaultPollDirectoryHelpers;
493impl PollDirectoryHelpers for DefaultPollDirectoryHelpers {}
494
495fn find_telemetry_files(ignore_age: bool) -> Result<Vec<PathBuf>> {
502 find_telemetry_files_with_read_dir_fn(ignore_age, |path| read_dir(path))
503}
504
505fn find_telemetry_files_with_read_dir_fn(
506 ignore_age: bool,
507 read_dir_fn: impl FnOnce(&str) -> std::result::Result<ReadDir, std::io::Error>,
508) -> Result<Vec<PathBuf>> {
509 let poll_interval = config()?.poll_interval;
510 let telemetry_dir = &telemetry_config()?.fuelup_tmp;
511
512 read_dir_fn(telemetry_dir)?
516 .filter_map(std::result::Result::ok)
517 .map(|e| e.path())
518 .filter(|p| p.is_file())
519 .filter(|p| p.to_string_lossy().contains(".telemetry."))
520 .filter_map(|path| {
521 if ignore_age
522 || poll_interval < path.metadata().ok()?.modified().ok()?.elapsed().ok()?
523 {
524 Some(Ok(path))
525 } else {
526 None
527 }
528 })
529 .collect::<Result<Vec<_>>>()
530}
531
532#[cfg(test)]
533mod config {
534 use super::*;
535 use crate::setup_fuelup_home;
536 use dirs::home_dir;
537 use rusty_fork::rusty_fork_test;
538 use std::env::set_var;
539
540 rusty_fork_test! {
541 #[test]
542 fn all_unset() {
543 let config = config().unwrap();
544
545 assert_eq!(
546 config.lockfile,
547 Path::new(
548 &home_dir()
549 .unwrap()
550 .join(format!(".fuelup/tmp/{}.lock", PROCESS_NAME))
551 )
552 );
553 assert_eq!(
554 config.logfile,
555 Path::new(
556 &home_dir()
557 .unwrap()
558 .join(format!(".fuelup/log/{}.log", PROCESS_NAME))
559 )
560 );
561 assert_eq!(config.poll_interval, Duration::from_secs(3600));
562 assert_eq!(config.influxdb_token, INFLUXDB_TOKEN);
563
564 assert_eq!(config.influxdb_url, format!(
565 "{}/api/v2/write?org={}&bucket={}&precision=ns",
566 INFLUXDB_URL,
567 INFLUXDB_ORG,
568 INFLUXDB_BUCKET
569 ));
570 }
571
572 #[test]
573 fn poll_interval_set() {
574 set_var("FILEWATCHER_POLL_INTERVAL", "2222");
575
576 let config = config().unwrap();
577 assert_eq!(config.poll_interval, Duration::from_secs(2222));
578 }
579
580 #[test]
581 fn poll_interval_invalid() {
582 set_var("FILEWATCHER_POLL_INTERVAL", "invalid");
583
584 let config = config();
585 assert_eq!(
586 config.err(),
587 Some(TelemetryError::InvalidConfig(
588 TelemetryError::Parse("invalid digit found in string".to_string()).into()
589 ))
590 );
591 }
592
593 #[test]
594 fn influxdb_url_set() {
595 set_var("INFLUXDB_URL", "http://localhost:8000");
596
597 let config = config().unwrap();
598 assert_eq!(
599 config.influxdb_url,
600 format!("{}/api/v2/write?org={}&bucket={}&precision=ns",
601 "http://localhost:8000",
602 INFLUXDB_ORG,
603 INFLUXDB_BUCKET
604 ));
605 }
606
607 #[test]
608 fn influxdb_org_set() {
609 set_var("INFLUXDB_ORG", "org-name");
610
611 let config = config().unwrap();
612 assert_eq!(config.influxdb_url, format!(
613 "{}/api/v2/write?org={}&bucket={}&precision=ns",
614 INFLUXDB_URL,
615 "org-name",
616 INFLUXDB_BUCKET
617 ));
618 }
619
620 #[test]
621 fn influxdb_bucket_set() {
622 set_var("INFLUXDB_BUCKET", "bucket-name");
623
624 let config = config().unwrap();
625 assert_eq!(config.influxdb_url, format!(
626 "{}/api/v2/write?org={}&bucket={}&precision=ns",
627 INFLUXDB_URL,
628 INFLUXDB_ORG,
629 "bucket-name"
630 ));
631 }
632
633 #[test]
634 fn all_set() {
635 setup_fuelup_home();
636 let fuelup_home = var("FUELUP_HOME").unwrap();
637
638 set_var("FILEWATCHER_POLL_INTERVAL", "2222");
639 set_var("INFLUXDB_URL", "http://localhost:8000");
640 set_var("INFLUXDB_ORG", "org-name");
641 set_var("INFLUXDB_BUCKET", "bucket-name");
642
643 let config = config().unwrap();
644
645 assert_eq!(
646 config.lockfile,
647 Path::new(&format!("{}/tmp/{}.lock", &fuelup_home, PROCESS_NAME))
648 );
649
650 assert_eq!(
651 config.logfile,
652 Path::new(&format!("{}/log/{}.log", &fuelup_home, PROCESS_NAME))
653 );
654 assert_eq!(config.poll_interval, Duration::from_secs(2222));
655 assert_eq!(config.influxdb_token, INFLUXDB_TOKEN);
656 assert_eq!(
657 config.influxdb_url,
658 format!("{}/api/v2/write?org={}&bucket={}&precision=ns",
659 "http://localhost:8000",
660 "org-name",
661 "bucket-name"
662 )
663 );
664 }
665 }
666}
667
668#[cfg(test)]
669mod new {
670 use super::*;
671 use rusty_fork::rusty_fork_test;
672
673 rusty_fork_test! {
674 #[test]
675 fn new() {
676 let file_watcher = FileWatcher::new();
677
678 assert!(file_watcher.client.is_none());
679 assert!(file_watcher.request.is_none());
680 }
681 }
682}
683
684#[cfg(test)]
685mod start {
686 use super::*;
687 use crate::{errors::into_fatal, setup_fuelup_home, WatcherError};
688 use nix::{
689 sys::wait::{waitpid, WaitStatus},
690 unistd::{dup2, fork, pipe, ForkResult},
691 };
692 use rusty_fork::rusty_fork_test;
693 use std::{
694 env::set_var,
695 fs::File,
696 io::{stdout, Read, Write},
697 os::fd::{AsRawFd, FromRawFd, IntoRawFd},
698 };
699
700 rusty_fork_test! {
701 #[test]
702 fn opted_out_is_true() {
703 set_var("FUELUP_NO_TELEMETRY", "true");
704
705 let mut file_watcher = FileWatcher::new();
706 let result = file_watcher.start();
707
708 assert!(matches!(result, Ok(())));
710 assert!(file_watcher.client.is_none());
711 assert!(file_watcher.request.is_none());
712 assert!(!STARTED.load(Ordering::Relaxed));
713 assert_eq!(PID.load(Ordering::Relaxed), 0);
714 }
715
716 #[test]
717 fn opted_out_is_empty() {
718 set_var("FUELUP_NO_TELEMETRY", "");
720
721 let mut file_watcher = FileWatcher::new();
722 let result = file_watcher.start();
723
724 assert!(matches!(result, Ok(())));
726 assert!(file_watcher.client.is_none());
727 assert!(file_watcher.request.is_none());
728 assert!(!STARTED.load(Ordering::Relaxed));
729 assert_eq!(PID.load(Ordering::Relaxed), 0);
730 }
731
732 #[test]
733 fn already_started() {
734 STARTED.store(true, Ordering::Relaxed);
735 PID.store(1, Ordering::Relaxed);
736
737 let mut file_watcher = FileWatcher::new();
738 let result = file_watcher.start();
739
740 assert!(matches!(result, Ok(())));
742 assert!(file_watcher.client.is_none());
743 assert!(file_watcher.request.is_none());
744 assert!(STARTED.load(Ordering::Relaxed));
745 assert_eq!(PID.load(Ordering::Relaxed), 1);
746
747 let result = file_watcher.start();
749
750 assert!(matches!(result, Ok(())));
751 assert!(file_watcher.client.is_none());
752 assert!(file_watcher.request.is_none());
753 assert!(STARTED.load(Ordering::Relaxed));
754 assert_eq!(PID.load(Ordering::Relaxed), 1);
755 }
756
757 #[test]
758 fn daemonise_failed() {
759 struct DaemoniseFailed;
760
761 impl StartHelpers for DaemoniseFailed {
762 fn daemonise(&self, _logfile: &PathBuf) -> WatcherResult<Option<Pid>> {
763 Err(into_fatal(TelemetryError::Mock))
764 }
765 }
766
767 let mut file_watcher = FileWatcher::new();
768 let result = file_watcher.start_with_helpers(&DaemoniseFailed);
769
770 assert_eq!(
771 result.err(),
772 Some(WatcherError::Fatal(TelemetryError::Mock))
773 );
774 assert!(!STARTED.load(Ordering::Relaxed));
775 assert_eq!(PID.load(Ordering::Relaxed), 0);
776 }
777
778 #[test]
779 fn daemonise_is_parent() {
780 struct DaemoniseIsParent;
781
782 impl StartHelpers for DaemoniseIsParent {
783 fn daemonise(&self, _logfile: &PathBuf) -> WatcherResult<Option<Pid>> {
784 Ok(Some(Pid::from_raw(1337)))
785 }
786 }
787
788 let mut file_watcher = FileWatcher::new();
789 let result = file_watcher.start_with_helpers(&DaemoniseIsParent);
790
791 assert_eq!(result, Ok(()));
792 assert!(STARTED.load(Ordering::Relaxed));
793 assert_eq!(PID.load(Ordering::Relaxed), 1337);
794 }
795
796 #[test]
797 fn build_request_invalid_client() {
798 struct BuildRequestInvalidClient;
799
800 impl StartHelpers for BuildRequestInvalidClient {
801 fn daemonise(&self, _logfile: &PathBuf) -> WatcherResult<Option<Pid>> {
802 Ok(None)
803 }
804
805 fn build_request(&self, _file_watcher: &FileWatcher) -> Result<Request> {
806 Err(TelemetryError::InvalidClient)
807 }
808 }
809
810 let mut file_watcher = FileWatcher::new();
811 let result = file_watcher.start_with_helpers(&BuildRequestInvalidClient);
812
813 assert_eq!(
814 result,
815 Err(WatcherError::Fatal(TelemetryError::InvalidClient))
816 );
817 }
818
819 #[test]
820 fn build_request_build_failed() {
821 setup_fuelup_home();
822
823 struct BuildRequestBuildFailed;
824
825 impl StartHelpers for BuildRequestBuildFailed {
826 fn daemonise(&self, _logfile: &PathBuf) -> WatcherResult<Option<Pid>> {
827 Ok(None)
828 }
829
830 fn build_request(&self, _file_watcher: &FileWatcher) -> Result<Request> {
831 Ok(Client::new()
833 .post("http://localhost")
834 .header("Invalid Header", "")
835 .build()?)
836 }
837 }
838
839 let mut file_watcher = FileWatcher::new();
840 let result = file_watcher.start_with_helpers(&BuildRequestBuildFailed);
841
842 assert_eq!(
843 result,
844 Err(WatcherError::Fatal(TelemetryError::Reqwest(
845 "builder error".to_string()
846 )))
847 );
848 }
849
850 #[test]
851 fn enforce_singleton_failed() {
852 struct EnforceSingletonFailed;
853
854 impl StartHelpers for EnforceSingletonFailed {
855 fn daemonise(&self, _logfile: &PathBuf) -> WatcherResult<Option<Pid>> {
856 Ok(None)
857 }
858
859 fn enforce_singleton(&self, _lockfile_path: &Path) -> Result<Flock<File>> {
860 Err(TelemetryError::Mock)
861 }
862 }
863
864 let mut file_watcher = FileWatcher::new();
865 let result = file_watcher.start_with_helpers(&EnforceSingletonFailed);
866
867 assert_eq!(result, Err(WatcherError::Fatal(TelemetryError::Mock)));
868 }
869
870 #[test]
871 fn poll_directory_failed() {
872 setup_fuelup_home();
873
874 struct PollDirectoryFailed;
875
876 impl StartHelpers for PollDirectoryFailed {
877 fn daemonise(&self, _logfile: &PathBuf) -> WatcherResult<Option<Pid>> {
878 Ok(None)
879 }
880
881 fn poll_directory(&self, _file_watcher: &FileWatcher) -> Result<bool> {
882 Err(TelemetryError::Mock)
883 }
884 }
885
886 let mut file_watcher = FileWatcher::new();
887 let result = file_watcher.start_with_helpers(&PollDirectoryFailed);
888
889 assert_eq!(result, Err(WatcherError::Fatal(TelemetryError::Mock)));
890 }
891
892 #[test]
893 fn poll() {
894 setup_fuelup_home();
895
896 struct Poll;
897
898 impl StartHelpers for Poll {
899 fn daemonise(&self, _logfile: &PathBuf) -> WatcherResult<Option<Pid>> {
900 Ok(None)
902 }
903
904 fn poll_directory(&self, _file_watcher: &FileWatcher) -> Result<bool> {
905 static HAS_POLLED: AtomicBool = AtomicBool::new(false);
906
907 if !HAS_POLLED.load(Ordering::Relaxed) {
908 HAS_POLLED.store(true, Ordering::Relaxed);
909
910 let file_watcher_pid = crate::file_watcher::PID
912 .load(Ordering::Relaxed)
913 .as_raw_fd()
914 .to_ne_bytes();
915
916 stdout().write_all(&file_watcher_pid).unwrap();
917 stdout().flush().unwrap();
918
919 Ok(false)
920 } else {
921 Ok(true)
922 }
923 }
924
925 fn exit(&self, _code: i32) {
926 exit(99)
928 }
929
930 fn sleep(&self, _duration: Duration) {
931 }
933 }
934
935 let (read_fd, write_fd) = pipe().unwrap();
936 let mut pipe_read = unsafe { File::from_raw_fd(read_fd.into_raw_fd()) };
937 let pipe_write = unsafe { File::from_raw_fd(write_fd.into_raw_fd()) };
938
939 match unsafe { fork() }.unwrap() {
940 ForkResult::Parent { child } => {
941 drop(pipe_write);
942
943 let mut pid_bytes = [0u8; std::mem::size_of::<Pid>()];
944 pipe_read.read_exact(&mut pid_bytes).unwrap();
945
946 let pid = Pid::from_raw(i32::from_ne_bytes(pid_bytes));
947 assert_eq!(pid, child);
948
949 match waitpid(child, None).unwrap() {
950 WaitStatus::Exited(_, code) => {
951 assert_eq!(code, 99);
952 }
953 _ => panic!("Child did not exit normally"),
954 }
955 }
956 ForkResult::Child => {
957 drop(pipe_read);
958 dup2(pipe_write.as_raw_fd(), 1).unwrap();
959
960 let pid_bytes = getpid().as_raw().to_ne_bytes();
963 stdout().write_all(&pid_bytes).unwrap();
964 stdout().flush().unwrap();
965
966 let mut file_watcher = FileWatcher::new();
967 let _ = file_watcher.start_with_helpers(&Poll);
968
969 exit(86);
971 }
972 }
973 }
974 }
975}
976
977#[cfg(test)]
978mod kill {
979 use super::*;
980 use nix::{
981 sys::{
982 signal::Signal,
983 wait::{waitpid, WaitPidFlag, WaitStatus},
984 },
985 unistd::{fork, ForkResult},
986 };
987 use rusty_fork::rusty_fork_test;
988 use std::thread::sleep;
989
990 rusty_fork_test! {
991 #[test]
992 fn kill_nobody() {
993 crate::file_watcher::PID.store(0, Ordering::Relaxed);
994 assert!(!FileWatcher::kill().unwrap());
995 }
996
997 #[test]
998 fn kill_file_watcher() {
999 let mut kill_called = false;
1000
1001 match unsafe { fork() }.unwrap() {
1002 ForkResult::Parent { child } => {
1003 loop {
1004 match waitpid(child, Some(WaitPidFlag::WNOHANG)) {
1005 Ok(WaitStatus::StillAlive) => {
1006 if !kill_called {
1007 crate::file_watcher::PID
1009 .store(child.as_raw(), Ordering::Relaxed);
1010 assert!(FileWatcher::kill().unwrap());
1011
1012 kill_called = true;
1013 continue;
1014 }
1015 }
1016 Ok(WaitStatus::Signaled(child_pid, signal, _)) => {
1017 assert_eq!(child_pid, child);
1018 assert_eq!(signal, Signal::SIGKILL);
1019 break;
1020 }
1021 _ => panic!("Child process terminated unexpectedly"),
1022 }
1023 }
1024 }
1025 ForkResult::Child => loop {
1026 sleep(Duration::from_secs(10));
1027 },
1028 }
1029 }
1030 }
1031}
1032
1033#[cfg(test)]
1034mod poll_directory {
1035 use super::*;
1036 use crate::setup_fuelup_home;
1037 use rusty_fork::rusty_fork_test;
1038 use std::{fs::File, io::Write, sync::OnceLock};
1039
1040 fn setup_test_file() -> PathBuf {
1041 static TEST_FILE: OnceLock<PathBuf> = OnceLock::new();
1042
1043 let filename = TEST_FILE.get_or_init(|| {
1044 let filename = format!("{}/{}", telemetry_config().unwrap().fuelup_tmp, "test.telemetry.file");
1045
1046 let mut file = File::create(Path::new(filename.as_str())).unwrap();
1047 file.write_all(b"MjAyNS0wMy0xNlQxNDo0ODoxMC4wODQ4MDMwMDBaICBJTkZPIGFhcmNoNjQtYXBwbGUtZGFyd2luOkRhcndpbjoyMy42LjAgc2ltcGxlOjAuMS4wOmV4YW1wbGVzL3NpbXBsZS5ycyA0YjEyNDYyNy0wNjNiLTQwM2UtODEyZi02OWQxMzRlZjAxMzIgYXV0bzogZ0Z1ZWwh").unwrap();
1048
1049 PathBuf::from(filename)
1050 });
1051
1052 filename.clone()
1053 }
1054
1055 rusty_fork_test! {
1056 #[test]
1057 fn find_telemetry_files_failed() {
1058 setup_fuelup_home();
1059
1060 struct FindTelemetryFilesFailed;
1061
1062 impl PollDirectoryHelpers for FindTelemetryFilesFailed {
1063 fn find_telemetry_files(&mut self, _ignore_age: bool) -> Result<Vec<PathBuf>> {
1064 Err(TelemetryError::Mock)
1065 }
1066 }
1067
1068 let file_watcher = FileWatcher::new();
1069 let result = file_watcher.poll_directory_with_helpers(&mut FindTelemetryFilesFailed);
1070
1071 assert_eq!(result, Err(TelemetryError::Mock));
1072 }
1073
1074 #[test]
1075 fn find_telemetry_files_empty() {
1076 setup_fuelup_home();
1077
1078 struct FindTelemetryFilesEmpty;
1079
1080 impl PollDirectoryHelpers for FindTelemetryFilesEmpty {
1081 fn find_telemetry_files(&mut self, _ignore_age: bool) -> Result<Vec<PathBuf>> {
1082 Ok(vec![])
1083 }
1084 }
1085
1086 let file_watcher = FileWatcher::new();
1087 let result = file_watcher.poll_directory_with_helpers(&mut FindTelemetryFilesEmpty);
1088
1089 assert_eq!(result, Ok(true));
1090 }
1091
1092 #[test]
1093 fn flock_failed() {
1094 setup_fuelup_home();
1095
1096 struct FlockFailed;
1097
1098 impl PollDirectoryHelpers for FlockFailed {
1099 fn find_telemetry_files(&mut self, _ignore_age: bool) -> Result<Vec<PathBuf>> {
1100 Ok(vec![setup_test_file()])
1101 }
1102
1103 fn flock(&self, file: File, _flags: FlockArg)-> std::result::Result<Flock<File>, (File, nix::errno::Errno)> {
1104 Err((file, nix::errno::Errno::EOWNERDEAD))
1105 }
1106 }
1107
1108 let file_watcher = FileWatcher::new();
1109 let result = file_watcher.poll_directory_with_helpers(&mut FlockFailed);
1110
1111 assert_eq!(
1112 result,
1113 Err(TelemetryError::Nix(nix::errno::Errno::EOWNERDEAD.to_string()))
1114 );
1115 }
1116
1117 #[test]
1118 fn buffered_reader_failed() {
1119 setup_fuelup_home();
1120
1121 struct BufferedReaderFailed;
1122
1123 impl PollDirectoryHelpers for BufferedReaderFailed {
1124 fn find_telemetry_files(&mut self, _ignore_age: bool) -> Result<Vec<PathBuf>> {
1125 Ok(vec![setup_test_file()])
1126 }
1127
1128 fn buffered_reader<R: Read>(&self, _file: R) -> std::result::Result<Vec<String>, std::io::Error> {
1129 Err(std::io::Error::new(std::io::ErrorKind::Other, "Mock error"))
1130 }
1131 }
1132
1133 let file_watcher = FileWatcher::new();
1134 let result = file_watcher.poll_directory_with_helpers(&mut BufferedReaderFailed);
1135
1136 assert_eq!(
1137 result,
1138 Err(TelemetryError::IO("Mock error".to_string()))
1139 );
1140 }
1141
1142 #[test]
1143 fn empty_file() {
1144 setup_fuelup_home();
1145
1146 struct EmptyFile;
1147
1148 impl PollDirectoryHelpers for EmptyFile {
1149 fn find_telemetry_files(&mut self, _ignore_age: bool) -> Result<Vec<PathBuf>> {
1150 Ok(vec![setup_test_file()])
1151 }
1152
1153 fn buffered_reader<R: Read>(&self, _file: R) -> std::result::Result<Vec<String>, std::io::Error> {
1154 Ok(vec![])
1155 }
1156 }
1157
1158 let file_watcher = FileWatcher::new();
1159 let result = file_watcher.poll_directory_with_helpers(&mut EmptyFile);
1160
1161 assert_eq!(result, Ok(false));
1163 }
1164
1165 #[test]
1166 fn base64_decode_failed() {
1167 setup_fuelup_home();
1168
1169 struct Base64DecodeFailed;
1170
1171 impl PollDirectoryHelpers for Base64DecodeFailed {
1172 fn find_telemetry_files(&mut self, _ignore_age: bool) -> Result<Vec<PathBuf>> {
1173 Ok(vec![setup_test_file()])
1174 }
1175
1176 fn base64_decode(&self, _line: &str) -> std::result::Result<Vec<u8>, DecodeError> {
1177 Err(DecodeError::InvalidLength(111222333444555))
1178 }
1179 }
1180
1181 let file_watcher = FileWatcher::new();
1182 let result = file_watcher.poll_directory_with_helpers(&mut Base64DecodeFailed);
1183
1184 assert_eq!(
1185 result,
1186 Err(TelemetryError::Base64(DecodeError::InvalidLength(111222333444555)))
1187 );
1188 }
1189
1190 #[test]
1191 fn string_from_utf8_failed() {
1192 setup_fuelup_home();
1193
1194 struct StringFromUtf8Failed;
1195
1196 impl PollDirectoryHelpers for StringFromUtf8Failed {
1197 fn find_telemetry_files(&mut self, _ignore_age: bool) -> Result<Vec<PathBuf>> {
1198 Ok(vec![setup_test_file()])
1199 }
1200
1201 fn string_from_utf8(&mut self, _bytes: Vec<u8>) -> std::result::Result<String, std::string::FromUtf8Error> {
1202 String::from_utf8(vec![0x80])
1204 }
1205 }
1206
1207 let file_watcher = FileWatcher::new();
1208 let result = file_watcher.poll_directory_with_helpers(&mut StringFromUtf8Failed);
1209
1210 assert_eq!(
1211 result,
1212 Err(TelemetryError::Utf8("invalid utf-8 sequence of 1 bytes from index 0".to_string()))
1213 );
1214 }
1215
1216 #[test]
1217 fn parse_event_failed() {
1218 setup_fuelup_home();
1219
1220 struct ParseEventFailed;
1221
1222 impl PollDirectoryHelpers for ParseEventFailed {
1223 fn find_telemetry_files(&mut self, _ignore_age: bool) -> Result<Vec<PathBuf>> {
1224 Ok(vec![setup_test_file()])
1225 }
1226
1227 fn parse_event<'a>(&self, _line: &'a str) -> Result<(
1228 Captures<'a>,
1229 Vec<String>,
1230 Vec<String>,
1231 HashMap<&'a str, String>,
1232 )> {
1233 Err(TelemetryError::Mock)
1234 }
1235 }
1236
1237 let file_watcher = FileWatcher::new();
1238 let result = file_watcher.poll_directory_with_helpers(&mut ParseEventFailed);
1239
1240 assert_eq!(result, Err(TelemetryError::Mock));
1241 }
1242
1243 #[test]
1244 fn line_protocol_from_utf8_failed() {
1245 setup_fuelup_home();
1246
1247 #[derive(Default)]
1248 struct LineProtocolFromUtf8Failed {
1249 call_counter: usize,
1250 }
1251
1252 impl PollDirectoryHelpers for LineProtocolFromUtf8Failed {
1253 fn find_telemetry_files(&mut self, _ignore_age: bool) -> Result<Vec<PathBuf>> {
1254 Ok(vec![setup_test_file()])
1255 }
1256
1257 fn string_from_utf8(&mut self, bytes: Vec<u8>) -> std::result::Result<String, std::string::FromUtf8Error> {
1258 self.call_counter += 1;
1259
1260 if self.call_counter == 2 {
1261 String::from_utf8(vec![0x80])
1262 } else {
1263 String::from_utf8(bytes)
1264 }
1265 }
1266 }
1267
1268 let file_watcher = FileWatcher::new();
1269 let result = file_watcher.poll_directory_with_helpers(&mut LineProtocolFromUtf8Failed::default());
1270
1271 assert_eq!(
1272 result,
1273 Err(TelemetryError::Utf8("invalid utf-8 sequence of 1 bytes from index 0".to_string()))
1274 );
1275 }
1276
1277 #[test]
1278 fn build_request_failed() {
1279 setup_fuelup_home();
1280
1281 struct BuildRequestFailed;
1282
1283 impl PollDirectoryHelpers for BuildRequestFailed {
1284 fn find_telemetry_files(&mut self, _ignore_age: bool) -> Result<Vec<PathBuf>> {
1285 Ok(vec![setup_test_file()])
1286 }
1287
1288 fn build_request(&self, _file_watcher: &FileWatcher, _body: &[String]) -> Result<RequestBuilder> {
1289 Err(TelemetryError::Mock)
1290 }
1291 }
1292
1293 let file_watcher = FileWatcher::new();
1294 let result = file_watcher.poll_directory_with_helpers(&mut BuildRequestFailed);
1295
1296 assert_eq!(result, Err(TelemetryError::Mock));
1297 }
1298
1299 #[test]
1300 fn send_request_failed() {
1301 setup_fuelup_home();
1302
1303 struct SendRequestFailed;
1304
1305
1306 impl PollDirectoryHelpers for SendRequestFailed {
1307 fn find_telemetry_files(&mut self, _ignore_age: bool) -> Result<Vec<PathBuf>> {
1308 Ok(vec![setup_test_file()])
1309 }
1310
1311 fn send_request(&self, _request: RequestBuilder) -> Result<bool> {
1312 Err(TelemetryError::Mock)
1313 }
1314 }
1315
1316 let mut file_watcher = FileWatcher::new();
1317 let start_helpers = DefaultStartHelpers;
1318
1319 file_watcher.client = Some(Client::new());
1320 file_watcher.request = Some(start_helpers.build_request(&file_watcher).unwrap());
1321
1322 let result = file_watcher.poll_directory_with_helpers(&mut SendRequestFailed);
1323 assert_eq!(result, Err(TelemetryError::Mock));
1324
1325 let test_file = setup_test_file();
1326 assert!(test_file.exists());
1327 }
1328
1329 #[test]
1330 fn remove_file_failed() {
1331 setup_fuelup_home();
1332
1333 struct RemoveFileFailed;
1334
1335 impl PollDirectoryHelpers for RemoveFileFailed {
1336 fn find_telemetry_files(&mut self, _ignore_age: bool) -> Result<Vec<PathBuf>> {
1337 Ok(vec![setup_test_file()])
1338 }
1339
1340 fn send_request(&self, _request: RequestBuilder) -> Result<bool> {
1341 Ok(true)
1342 }
1343
1344 fn remove_file(&self, _file: &Path) -> std::io::Result<()> {
1345 Err(std::io::Error::new(std::io::ErrorKind::Other, "Mock error"))
1346 }
1347 }
1348
1349 let mut file_watcher = FileWatcher::new();
1350 let start_helpers = DefaultStartHelpers;
1351
1352 file_watcher.client = Some(Client::new());
1353 file_watcher.request = Some(start_helpers.build_request(&file_watcher).unwrap());
1354
1355 let result = file_watcher.poll_directory_with_helpers(&mut RemoveFileFailed);
1356 assert_eq!(result, Err(TelemetryError::IO("Mock error".to_string())));
1357
1358 let test_file = setup_test_file();
1359 assert!(test_file.exists());
1360 }
1361
1362 #[test]
1363 fn ok() {
1364 setup_fuelup_home();
1365
1366 #[derive(Default)]
1367 struct AOk {
1368 call_count: usize,
1369 }
1370
1371 impl PollDirectoryHelpers for AOk {
1372 fn find_telemetry_files(&mut self, _ignore_age: bool) -> Result<Vec<PathBuf>> {
1373 self.call_count += 1;
1374
1375 if self.call_count == 2 {
1376 Ok(vec![])
1377 } else {
1378 Ok(vec![setup_test_file()])
1379 }
1380 }
1381
1382 fn send_request(&self, _request: RequestBuilder) -> Result<bool> {
1383 Ok(true)
1384 }
1385 }
1386
1387 let mut file_watcher = FileWatcher::new();
1388 let start_helpers = DefaultStartHelpers;
1389
1390 file_watcher.client = Some(Client::new());
1391 file_watcher.request = Some(start_helpers.build_request(&file_watcher).unwrap());
1392
1393 let result = file_watcher.poll_directory_with_helpers(&mut AOk::default());
1394 assert_eq!(result, Ok(true));
1395
1396 let test_file = setup_test_file();
1397 assert!(!test_file.exists());
1398 }
1399 }
1400}
1401
1402#[cfg(test)]
1403mod find_telemetry_files {
1404 use super::*;
1405 use crate::setup_fuelup_home;
1406 use rusty_fork::rusty_fork_test;
1407 use std::{fs::File, io::Write, time::SystemTime};
1408
1409 fn setup_test_files() {
1410 for filename in [
1411 "old-file.telemetry.file",
1412 "new-file.telemetry.file",
1413 "invalid.file.name",
1414 ] {
1415 let filepath = format!("{}/{}", telemetry_config().unwrap().fuelup_tmp, filename);
1416 let mut file = File::create(Path::new(filepath.as_str())).unwrap();
1417 file.write_all(b"MjAyNS0wMy0xNlQxNDo0ODoxMC4wODQ4MDMwMDBaICBJTkZPIGFhcmNoNjQtYXBwbGUtZGFyd2luOkRhcndpbjoyMy42LjAgc2ltcGxlOjAuMS4wOmV4YW1wbGVzL3NpbXBsZS5ycyA0YjEyNDYyNy0wNjNiLTQwM2UtODEyZi02OWQxMzRlZjAxMzIgYXV0bzogZ0Z1ZWwh").unwrap();
1418 }
1419
1420 let old_file = File::open(Path::new(
1421 format!(
1422 "{}/old-file.telemetry.file",
1423 telemetry_config().unwrap().fuelup_tmp
1424 )
1425 .as_str(),
1426 ))
1427 .unwrap();
1428
1429 old_file
1430 .set_modified(
1431 SystemTime::now() - Duration::from_secs(config().unwrap().poll_interval.as_secs()),
1432 )
1433 .unwrap();
1434
1435 for dir in ["dir1", "dir2", "dir3"] {
1436 let dir = format!("{}/{}", telemetry_config().unwrap().fuelup_tmp, dir);
1437 std::fs::create_dir_all(dir).unwrap();
1438 }
1439 }
1440
1441 rusty_fork_test! {
1442 #[test]
1443 fn read_dir_failed() {
1444 setup_fuelup_home();
1445 setup_test_files();
1446
1447 let result = find_telemetry_files_with_read_dir_fn(
1448 false,
1449 |_| Err(std::io::Error::new(std::io::ErrorKind::Other, "Mock error")),
1450 );
1451
1452 assert_eq!(result, Err(TelemetryError::IO("Mock error".to_string())));
1453 }
1454
1455 #[test]
1456 fn ok() {
1457 setup_fuelup_home();
1458 setup_test_files();
1459
1460 let result = find_telemetry_files(false);
1461
1462 assert_eq!(result, Ok(vec![
1463 PathBuf::from(format!("{}/old-file.telemetry.file", telemetry_config().unwrap().fuelup_tmp)),
1464 ]));
1465 }
1466 }
1467}