Skip to main content

datui_lib/
logging.rs

1//! The log file, and keeping stray stderr off the screen.
2//!
3//! Anything written to stderr while the TUI owns the terminal is drawn over it, and
4//! Polars, its verbose mode and C libraries all write there. So while the TUI runs,
5//! fd 2 points at the log file, Polars warnings are routed through the `log` facade,
6//! and errors that are not worth stopping for (cache, history) land here instead of
7//! vanishing.
8
9use std::cell::Cell;
10use std::collections::{HashSet, VecDeque};
11use std::fs::{File, OpenOptions};
12use std::io::Write;
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
15use std::sync::{LazyLock, Mutex};
16
17pub use log::LevelFilter;
18use log::{Log, Metadata, Record};
19
20/// The log's name in the cache directory.
21pub const LOG_FILE_NAME: &str = "datui.log";
22
23/// Past this the log is moved aside to `<name>.1`, replacing the previous one.
24const MAX_BYTES: u64 = 1024 * 1024;
25
26/// The level when `DATUI_LOG` is unset.
27const DEFAULT_LEVEL: LevelFilter = LevelFilter::Warn;
28
29/// Where the log goes and how much of it.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct LogSettings {
32    /// `None` when logging is off; stray stderr is then discarded.
33    pub path: Option<PathBuf>,
34    pub level: LevelFilter,
35    /// `DATUI_LOG` held something that is not a level; said in the log once it opens.
36    pub unknown_level: Option<String>,
37}
38
39impl LogSettings {
40    /// Resolve the file from `[log] file` (which `--log-file` overrides) or the
41    /// cache directory, and the level from `DATUI_LOG`.
42    pub fn resolve(
43        configured: Option<&str>,
44        level: Option<&str>,
45        cache_dir: Option<&Path>,
46    ) -> Self {
47        let (level, unknown_level) = match level.map(str::trim).filter(|l| !l.is_empty()) {
48            None => (DEFAULT_LEVEL, None),
49            Some(text) => match text.parse::<LevelFilter>() {
50                Ok(level) => (level, None),
51                Err(_) => (DEFAULT_LEVEL, Some(text.to_string())),
52            },
53        };
54        let path = match configured.map(str::trim).filter(|p| !p.is_empty()) {
55            Some(path) => Some(crate::config::expand_config_path(path)),
56            None => cache_dir.map(|dir| dir.join(LOG_FILE_NAME)),
57        };
58        Self {
59            path: path.filter(|_| level != LevelFilter::Off),
60            level,
61            unknown_level,
62        }
63    }
64}
65
66/// The rotated copy of `path`: `datui.log` → `datui.log.1`.
67pub fn rotated_path(path: &Path) -> PathBuf {
68    let mut name = path.as_os_str().to_owned();
69    name.push(".1");
70    PathBuf::from(name)
71}
72
73/// A size-capped log file with one rotation.
74pub struct FileLog {
75    path: PathBuf,
76    file: File,
77    cap: u64,
78}
79
80impl FileLog {
81    /// Open (creating) `path` for appending, rotating it first when already over `cap`.
82    pub fn open(path: &Path, cap: u64) -> std::io::Result<Self> {
83        if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
84            std::fs::create_dir_all(dir)?;
85        }
86        let file = Self::append(path)?;
87        let mut log = Self {
88            path: path.to_path_buf(),
89            file,
90            cap,
91        };
92        log.rotate_if_full()?;
93        Ok(log)
94    }
95
96    fn append(path: &Path) -> std::io::Result<File> {
97        // Append mode, so two sessions sharing the file interleave lines rather than
98        // overwrite each other.
99        OpenOptions::new().create(true).append(true).open(path)
100    }
101
102    /// Write one line, then rotate if that filled the file. Returns whether it rotated.
103    pub fn write_line(&mut self, line: &str) -> std::io::Result<bool> {
104        self.file.write_all(line.as_bytes())?;
105        if !line.ends_with('\n') {
106            self.file.write_all(b"\n")?;
107        }
108        self.rotate_if_full()
109    }
110
111    /// Measured on the file rather than counted, since another session may share it.
112    ///
113    /// Another session may already have moved the file aside, leaving this one writing
114    /// into `<name>.1`; renaming then would put that session's fresh log over the old
115    /// one. So the rename happens under a lock beside the log, and only when the file
116    /// at the path is itself full; either way the path is opened again.
117    fn rotate_if_full(&mut self) -> std::io::Result<bool> {
118        if self.file.metadata()?.len() <= self.cap {
119            return Ok(false);
120        }
121        let mut lock_name = self.path.as_os_str().to_owned();
122        lock_name.push(".lock");
123        let Some(_lock) =
124            crate::cache::lock_file(Path::new(&lock_name), std::time::Duration::from_millis(200))?
125        else {
126            // Busy: the next line tries again.
127            return Ok(false);
128        };
129        let full = std::fs::metadata(&self.path).is_ok_and(|m| m.len() > self.cap);
130        if full {
131            std::fs::rename(&self.path, rotated_path(&self.path))?;
132        }
133        self.file = Self::append(&self.path)?;
134        Ok(full)
135    }
136}
137
138struct State {
139    level: LevelFilter,
140    file: Option<FileLog>,
141    /// Whether this session has written its header line yet.
142    started: bool,
143    /// Values never written to the log as they are: keys, tokens, passwords.
144    secrets: Vec<String>,
145}
146
147static STATE: Mutex<State> = Mutex::new(State {
148    level: LevelFilter::Off,
149    file: None,
150    started: false,
151    secrets: Vec::new(),
152});
153
154fn state() -> std::sync::MutexGuard<'static, State> {
155    STATE.lock().unwrap_or_else(|e| e.into_inner())
156}
157
158struct Logger;
159
160static LOGGER: Logger = Logger;
161
162impl Log for Logger {
163    fn enabled(&self, metadata: &Metadata) -> bool {
164        metadata.level() <= state().level
165    }
166
167    fn log(&self, record: &Record) {
168        if self.enabled(record.metadata()) {
169            write_record(record.level().as_str(), record.target(), record.args());
170        }
171    }
172
173    fn flush(&self) {
174        if let Some(file) = state().file.as_mut() {
175            let _ = file.file.flush();
176        }
177    }
178}
179
180thread_local! {
181    /// Set while this thread writes a record. A panic in the middle of one runs the
182    /// hook, which logs; without this it would wait on the lock the thread holds.
183    static WRITING: Cell<bool> = const { Cell::new(false) };
184}
185
186/// Clears [`WRITING`] however the write ends.
187struct Writing;
188
189impl Writing {
190    fn enter() -> Option<Self> {
191        (!WRITING.with(|w| w.replace(true))).then_some(Self)
192    }
193}
194
195impl Drop for Writing {
196    fn drop(&mut self) {
197        WRITING.with(|w| w.set(false));
198    }
199}
200
201/// Write one record whatever the level, when the log is open.
202fn write_record(level: &str, target: &str, message: impl std::fmt::Display) {
203    let Some(_writing) = Writing::enter() else {
204        return;
205    };
206    // Formatted before the lock, in case formatting logs something itself.
207    let message = message.to_string().replace('\n', "\n    ");
208    let now = chrono::Local::now()
209        .format("%Y-%m-%d %H:%M:%S%.3f")
210        .to_string();
211    let mut state = state();
212    if state.file.is_none() {
213        return;
214    }
215    let mut text = String::new();
216    if !state.started {
217        state.started = true;
218        text.push_str(&format!(
219            "{now} ----- datui {} (pid {})\n",
220            env!("CARGO_PKG_VERSION"),
221            std::process::id()
222        ));
223    }
224    text.push_str(&format!(
225        "{now} {level:<5} {target}: {}",
226        redact(&message, &state.secrets)
227    ));
228    if let Some(file) = state.file.as_mut() {
229        // A log that cannot be written has nowhere to say so; stderr is the screen.
230        let _ = file.write_line(&text);
231    }
232}
233
234/// Open the log and install the logger and the Polars warning hook. Safe to call
235/// again (the Python binding runs the TUI once per `view`); the latest settings win.
236///
237/// Returns what to tell the user when the log cannot be opened. Not printed here: the
238/// TUI may already own the terminal, and stderr is then the log that failed.
239pub fn init(settings: &LogSettings) -> Option<String> {
240    static INSTALLED: std::sync::Once = std::sync::Once::new();
241    INSTALLED.call_once(|| {
242        let _ = log::set_logger(&LOGGER);
243        polars_error::set_warning_function(polars_warning);
244    });
245    let mut note = None;
246    let file = settings
247        .path
248        .as_deref()
249        .and_then(|path| match FileLog::open(path, MAX_BYTES) {
250            Ok(file) => Some(file),
251            Err(e) => {
252                note = Some(format!("cannot write the log {}: {e}", path.display()));
253                None
254            }
255        });
256    let level = if file.is_some() {
257        settings.level
258    } else {
259        LevelFilter::Off
260    };
261    {
262        let mut state = state();
263        state.file = file;
264        state.level = level;
265        state.started = false;
266    }
267    log::set_max_level(level);
268    keep_out_of_log_from_env();
269    if let Some(text) = &settings.unknown_level {
270        log::warn!(target: "datui", "DATUI_LOG={text} is not a level; using warn");
271    }
272    note
273}
274
275/// The file the log is writing to, if it is open.
276pub fn current_path() -> Option<PathBuf> {
277    state().file.as_ref().map(|f| f.path.clone())
278}
279
280/// Never write `secret` to the log as it is.
281pub fn keep_out_of_log(secret: &str) {
282    // Short values would mask ordinary words.
283    if secret.len() < 8 {
284        return;
285    }
286    let mut state = state();
287    if !state.secrets.iter().any(|s| s == secret) {
288        state.secrets.push(secret.to_string());
289    }
290}
291
292/// Values of variables whose names say they hold a credential, the `[cloud] env_files`
293/// ones included.
294fn keep_out_of_log_from_env() {
295    for (name, value) in crate::cloud_env::vars() {
296        if holds_a_credential(&name, &value) {
297            keep_out_of_log(&value);
298        }
299    }
300}
301
302/// Whether an environment variable's value is a credential to mask. The name decides,
303/// except that a file or directory is not the secret itself: masking the path in
304/// `AWS_WEB_IDENTITY_TOKEN_FILE` would hide every mention of it.
305fn holds_a_credential(name: &str, value: &str) -> bool {
306    let name = name.to_ascii_uppercase();
307    let named = [
308        "SECRET",
309        "TOKEN",
310        "PASSWORD",
311        "PASSWD",
312        "ACCESS_KEY",
313        "ACCOUNT_KEY",
314        "CONNECTION_STRING",
315    ]
316    .iter()
317    .any(|word| name.contains(word))
318        || name.ends_with("_KEY");
319    let names_a_place = [
320        "_FILE",
321        "_PATH",
322        "_DIR",
323        "_DIRECTORY",
324        "_HOME",
325        "_URL",
326        "_URI",
327    ]
328    .iter()
329    .any(|suffix| name.ends_with(suffix));
330    let path = Path::new(value);
331    named && !names_a_place && !(path.is_absolute() && path.exists())
332}
333
334/// Log a failure not worth stopping for (cache, history) instead of dropping it.
335pub trait LogFailure {
336    fn or_log(self, what: &str);
337}
338
339impl<T, E: std::fmt::Display> LogFailure for Result<T, E> {
340    fn or_log(self, what: &str) {
341        if let Err(e) = self {
342            log::warn!(target: "datui", "{what}: {e:#}");
343        }
344    }
345}
346
347/// Mask credentials in a log line: known secret values, the user and password in a
348/// URL, signed-URL and SAS query parameters, and authorization headers.
349pub fn redact(text: &str, secrets: &[String]) -> String {
350    static PATTERNS: LazyLock<Vec<regex::Regex>> = LazyLock::new(|| {
351        [
352            // scheme://user:password@host
353            r"(?i)(\b[a-z][a-z0-9+.-]*://)[^/\s:@]+:[^/\s@]+@",
354            // ?X-Amz-Signature=..., &sig=..., &token=..., or a SAS token on its own
355            r"(?i)((?:^|[?&;])(?:x-amz-signature|x-amz-credential|x-amz-security-token|x-goog-signature|x-goog-credential|signature|sig|token|access_token|api_key|apikey|key|password|secret)=)[^&\s]+",
356            // Authorization: Bearer ..., "authorization": "..."
357            r#"(?i)(authorization"?\s*[:=]\s*"?)[^"\r\n]+"#,
358            // Case-sensitive Basic, or "basic statistics" would lose its noun.
359            r"(\b(?i:bearer)\s+|\bBasic\s+)[A-Za-z0-9._~+/=-]{8,}",
360            // secret_access_key = ..., "session_token": "...", AccountKey=...
361            r#"(?i)((?:secret[_-]?access[_-]?key|session[_-]?token|account[_-]?key|client[_-]?secret|sas[_-]?token|password)"?\s*[:=]\s*"?)[^\s",;}]+"#,
362        ]
363        .iter()
364        .filter_map(|p| regex::Regex::new(p).ok())
365        .collect()
366    });
367    let mut out = text.to_string();
368    for secret in secrets {
369        if out.contains(secret.as_str()) {
370            out = out.replace(secret.as_str(), "***");
371        }
372    }
373    for pattern in PATTERNS.iter() {
374        out = pattern.replace_all(&out, "${1}***").into_owned();
375    }
376    out
377}
378
379/// Polars warnings already seen this session, and user warnings not yet flashed.
380struct PolarsWarnings {
381    seen: HashSet<String>,
382    unshown: VecDeque<String>,
383}
384
385static POLARS: Mutex<Option<PolarsWarnings>> = Mutex::new(None);
386
387/// A warning repeats for every chunk and every collect; past this many distinct
388/// ones, the rest are dropped rather than let them fill the log.
389const MAX_POLARS_WARNINGS: usize = 256;
390
391/// Where `polars_warn!` goes instead of `eprintln!`. Each distinct warning is logged
392/// once per session. A deprecation is about Polars' API, which the user cannot act
393/// on; a user warning can explain a surprising result, so it is also queued for the
394/// control bar.
395fn polars_warning(message: &str, kind: polars_error::PolarsWarning) {
396    use polars_error::PolarsWarning as W;
397    let text = message.split_whitespace().collect::<Vec<_>>().join(" ");
398    {
399        let mut polars = POLARS.lock().unwrap_or_else(|e| e.into_inner());
400        let polars = polars.get_or_insert_with(|| PolarsWarnings {
401            seen: HashSet::new(),
402            unshown: VecDeque::new(),
403        });
404        if polars.seen.len() >= MAX_POLARS_WARNINGS || !polars.seen.insert(text.clone()) {
405            return;
406        }
407        if matches!(kind, W::UserWarning | W::CategoricalRemappingWarning) {
408            polars.unshown.push_back(text.clone());
409            tell_the_loop();
410        }
411    }
412    log::warn!(target: "polars", "{kind:?}: {text}");
413}
414
415/// What wakes the run loop when there is news it has to come and look for: a warning
416/// queued for the control bar, a background panic nothing reported. The loop only
417/// wakes for events and deadlines, so without this either would wait for a key.
418static NEWS: Mutex<Option<Box<dyn Fn() + Send + Sync>>> = Mutex::new(None);
419
420fn tell_the_loop() {
421    if let Some(wake) = NEWS.lock().unwrap_or_else(|e| e.into_inner()).as_ref() {
422        wake();
423    }
424}
425
426/// The next Polars user warning not yet shown, for the control bar.
427pub fn next_polars_warning() -> Option<String> {
428    POLARS
429        .lock()
430        .unwrap_or_else(|e| e.into_inner())
431        .as_mut()
432        .and_then(|p| p.unshown.pop_front())
433}
434
435/// Forget which Polars warnings were seen, so a new session logs them again.
436fn reset_polars_warnings() {
437    *POLARS.lock().unwrap_or_else(|e| e.into_inner()) = None;
438}
439
440/// Set while a TUI session owns the terminal.
441static TUI_ACTIVE: AtomicBool = AtomicBool::new(false);
442
443/// The last panic on a background thread, to print if it takes the TUI thread down.
444static BACKGROUND_PANIC: Mutex<Option<String>> = Mutex::new(None);
445
446/// Background panics nothing has told the user about yet: those on threads that do
447/// not report their own (see [`catch_panic`]).
448static UNREPORTED_PANICS: AtomicUsize = AtomicUsize::new(0);
449
450thread_local! {
451    /// Set while [`catch_panic`] runs work on this thread, which then reports its own.
452    static REPORTS_ITS_PANICS: Cell<bool> = const { Cell::new(false) };
453}
454
455/// Run `work`, turning a panic into a message for the user. The hook has already
456/// logged the panic with its backtrace; the message says where. A panic on a Polars
457/// thread that this work was waiting on is resumed here, so it is reported here too.
458pub fn catch_panic<T>(work: impl FnOnce() -> T) -> Result<T, String> {
459    let reported = REPORTS_ITS_PANICS.with(|r| r.replace(true));
460    let unreported = UNREPORTED_PANICS.load(Ordering::SeqCst);
461    let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(work));
462    REPORTS_ITS_PANICS.with(|r| r.set(reported));
463    caught.map_err(|payload| {
464        // Those counted meanwhile were the Polars threads this work waited on.
465        UNREPORTED_PANICS.fetch_min(unreported, Ordering::SeqCst);
466        let what = payload
467            .downcast_ref::<&str>()
468            .map(|s| s.to_string())
469            .or_else(|| payload.downcast_ref::<String>().cloned())
470            .unwrap_or_else(|| "panic".to_string());
471        match current_path() {
472            Some(path) => format!("Internal error: {what}\n\nDetails: {}", path.display()),
473            None => format!("Internal error: {what}"),
474        }
475    })
476}
477
478/// What to flash when a background thread panicked with nothing to say so: a raw
479/// thread's result simply never arrives. `None` when none did since the last call.
480pub fn take_unreported_panic() -> Option<String> {
481    if UNREPORTED_PANICS.swap(0, Ordering::SeqCst) == 0 {
482        return None;
483    }
484    Some(match current_path() {
485        Some(path) => format!(
486            "A background task failed; see {}",
487            path.file_name().unwrap_or_default().to_string_lossy()
488        ),
489        None => "A background task failed".to_string(),
490    })
491}
492
493/// The span during which the TUI owns the terminal: stderr goes to the log (Unix),
494/// and a panic on a background thread is logged instead of printed over the screen.
495/// Dropping it, on every exit path, hands stderr back.
496pub struct TuiSession {
497    restore_terminal: fn(),
498}
499
500impl TuiSession {
501    /// Begin right after the terminal is taken. `restore_terminal` hands the screen
502    /// back if the TUI thread unwinds from a panic the hook never saw.
503    pub fn begin(restore_terminal: fn()) -> Self {
504        reset_polars_warnings();
505        *BACKGROUND_PANIC.lock().unwrap_or_else(|e| e.into_inner()) = None;
506        UNREPORTED_PANICS.store(0, Ordering::SeqCst);
507        #[cfg(unix)]
508        {
509            // Through the log's pipe even with no log open yet: the settings that open
510            // it are read after the session begins. Lines with no log are dropped.
511            stderr::redirect(true);
512        }
513        TUI_ACTIVE.store(true, Ordering::SeqCst);
514        install_panic_hook();
515        Self { restore_terminal }
516    }
517
518    /// Call `wake` whenever a background panic or a Polars warning is waiting for
519    /// [`take_unreported_panic`] or [`next_polars_warning`], for the session's length.
520    pub fn wake_with(&self, wake: impl Fn() + Send + Sync + 'static) {
521        *NEWS.lock().unwrap_or_else(|e| e.into_inner()) = Some(Box::new(wake));
522    }
523}
524
525impl Drop for TuiSession {
526    fn drop(&mut self) {
527        NEWS.lock().unwrap_or_else(|e| e.into_inner()).take();
528        // Already inactive when the hook saw this thread panic: it restored stderr, and
529        // the hooks below it the terminal. Restoring again would pop the shell's
530        // keyboard flags rather than ours.
531        let hook_handled_it = !TUI_ACTIVE.swap(false, Ordering::SeqCst);
532        #[cfg(unix)]
533        stderr::restore();
534        // A panic resumed from another thread (a Polars worker's, say) unwinds this one
535        // without running the hook, so the terminal and the message are ours to handle.
536        if std::thread::panicking() && !hook_handled_it {
537            (self.restore_terminal)();
538            if let Some(message) = BACKGROUND_PANIC
539                .lock()
540                .unwrap_or_else(|e| e.into_inner())
541                .take()
542            {
543                eprintln!("{message}");
544            }
545        }
546    }
547}
548
549/// Wraps whatever hook is installed (color-eyre's, under ratatui's). Installed per
550/// session, above the hook `ratatui::try_init` adds each time.
551fn install_panic_hook() {
552    let tui_thread = std::thread::current().id();
553    let previous = std::panic::take_hook();
554    std::panic::set_hook(Box::new(move |info| {
555        if !TUI_ACTIVE.load(Ordering::SeqCst) {
556            previous(info);
557            return;
558        }
559        if std::thread::current().id() != tui_thread {
560            // A worker's panic is caught (tokio's blocking pool, or a catch_unwind)
561            // and the app carries on, so it must not tear down the screen.
562            let message = format!(
563                "a background thread panicked at {}: {}\n{}",
564                info.location()
565                    .map(|l| l.to_string())
566                    .unwrap_or_else(|| "?".into()),
567                panic_payload(info),
568                std::backtrace::Backtrace::force_capture()
569            );
570            log::error!(target: "datui::panic", "{message}");
571            *BACKGROUND_PANIC.lock().unwrap_or_else(|e| e.into_inner()) = Some(message);
572            if !REPORTS_ITS_PANICS.with(Cell::get) {
573                UNREPORTED_PANICS.fetch_add(1, Ordering::SeqCst);
574                tell_the_loop();
575            }
576            return;
577        }
578        // The TUI thread: hand stderr back so the report reaches the terminal. Inactive
579        // from here, so an earlier session's hook further down the chain (the Python
580        // binding, run from another thread) passes the report on instead of keeping it.
581        TUI_ACTIVE.store(false, Ordering::SeqCst);
582        // The hooks below hand back the screen but not the mouse, whose reporting
583        // outlives the alternate screen: the shell would read every click as text.
584        // Unlike popping the keyboard flags, this is safe to repeat.
585        let _ = crossterm::execute!(std::io::stdout(), crossterm::event::DisableMouseCapture);
586        BACKGROUND_PANIC
587            .lock()
588            .unwrap_or_else(|e| e.into_inner())
589            .take();
590        #[cfg(unix)]
591        stderr::restore();
592        previous(info);
593    }));
594}
595
596fn panic_payload(info: &std::panic::PanicHookInfo<'_>) -> String {
597    let payload = info.payload();
598    payload
599        .downcast_ref::<&str>()
600        .map(|s| s.to_string())
601        .or_else(|| payload.downcast_ref::<String>().cloned())
602        .unwrap_or_else(|| "panic".to_string())
603}
604
605/// One line someone wrote to stderr while the TUI was up.
606#[cfg(unix)]
607fn write_stray_line(line: &[u8]) {
608    let text = String::from_utf8_lossy(line);
609    let text = text.trim_end_matches(['\n', '\r']);
610    if !text.trim().is_empty() {
611        write_record("WARN", "stderr", text);
612    }
613}
614
615/// Pointing fd 2 away from the terminal and back. Windows is left alone: there the
616/// Polars hook is what keeps its warnings off the screen.
617///
618/// With the log open, fd 2 becomes a pipe that a thread reads into the log line by
619/// line, so stray output is masked and counted toward the cap like any other record.
620#[cfg(unix)]
621mod stderr {
622    use std::io::BufRead;
623    use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
624    use std::sync::Mutex;
625    use std::sync::mpsc::{Receiver, channel};
626    use std::time::Duration;
627
628    struct Redirect {
629        /// A duplicate of the real stderr.
630        terminal: OwnedFd,
631        /// Answers once the reader has read the pipe to its end; `None` for /dev/null.
632        drained: Option<Receiver<()>>,
633    }
634
635    static REDIRECT: Mutex<Option<Redirect>> = Mutex::new(None);
636
637    fn current() -> std::sync::MutexGuard<'static, Option<Redirect>> {
638        REDIRECT.lock().unwrap_or_else(|e| e.into_inner())
639    }
640
641    /// Point fd 2 at the log, or at `/dev/null` with logging off.
642    pub(super) fn redirect(logging: bool) {
643        let mut current = current();
644        if current.is_some() {
645            return;
646        }
647        // SAFETY: fcntl(F_DUPFD_CLOEXEC) reads no memory; on failure it returns -1.
648        // Close-on-exec, so a child process never inherits the terminal through it.
649        let copy = unsafe { libc::fcntl(libc::STDERR_FILENO, libc::F_DUPFD_CLOEXEC, 0) };
650        if copy < 0 {
651            return;
652        }
653        // SAFETY: `copy` is a new descriptor that nothing else owns.
654        let terminal = unsafe { OwnedFd::from_raw_fd(copy) };
655        let target = logging.then(into_the_log).flatten().or_else(|| {
656            std::fs::OpenOptions::new()
657                .write(true)
658                .open("/dev/null")
659                .ok()
660                .map(|null| (OwnedFd::from(null), None))
661        });
662        // `target` closes at the end of this function, which leaves fd 2 as the pipe's
663        // only writer: once fd 2 points back at the terminal, the reader sees the end.
664        if let Some((target, drained)) = target
665            && point(target.as_raw_fd())
666        {
667            *current = Some(Redirect { terminal, drained });
668        }
669    }
670
671    /// A pipe whose other end a thread copies into the log, line by line.
672    fn into_the_log() -> Option<(OwnedFd, Option<Receiver<()>>)> {
673        let (reader, writer) = std::io::pipe().ok()?;
674        let (done, drained) = channel();
675        std::thread::Builder::new()
676            .name("datui-stderr".into())
677            .spawn(move || {
678                let mut reader = std::io::BufReader::new(reader);
679                let mut line = Vec::new();
680                loop {
681                    line.clear();
682                    match reader.read_until(b'\n', &mut line) {
683                        Ok(0) => break,
684                        // Caught, because a reader that died would close the pipe and
685                        // turn every later `eprintln!` into a panic of its own.
686                        Ok(_) => {
687                            let _ = std::panic::catch_unwind(|| super::write_stray_line(&line));
688                        }
689                        Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
690                        Err(_) => break,
691                    }
692                }
693                let _ = done.send(());
694            })
695            .ok()?;
696        Some((writer.into(), Some(drained)))
697    }
698
699    /// Hand the real stderr back. A no-op when it is not redirected.
700    pub(super) fn restore() {
701        let Some(redirect) = current().take() else {
702            return;
703        };
704        point(redirect.terminal.as_raw_fd());
705        // Let the reader finish what was written just before, so a panic report or a
706        // last warning is in the log. Bounded: a child process that inherited fd 2
707        // holds the pipe open for as long as it runs.
708        if let Some(drained) = redirect.drained {
709            let _ = drained.recv_timeout(Duration::from_millis(500));
710        }
711    }
712
713    fn point(fd: RawFd) -> bool {
714        // SAFETY: dup2 reads no memory; `fd` is open, as the caller holds it. fd 2 is
715        // replaced atomically, so a concurrent write lands on one file or the other.
716        unsafe { libc::dup2(fd, libc::STDERR_FILENO) >= 0 }
717    }
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723
724    #[test]
725    fn the_log_goes_to_the_cache_unless_configured() {
726        let cache = Path::new("/cache/datui");
727        let default = LogSettings::resolve(None, None, Some(cache));
728        assert_eq!(default.path, Some(cache.join("datui.log")));
729        assert_eq!(default.level, LevelFilter::Warn);
730
731        let chosen = LogSettings::resolve(Some("/elsewhere/x.log"), Some("debug"), Some(cache));
732        assert_eq!(chosen.path, Some(PathBuf::from("/elsewhere/x.log")));
733        assert_eq!(chosen.level, LevelFilter::Debug);
734
735        let blank = LogSettings::resolve(Some("  "), Some(" "), Some(cache));
736        assert_eq!(blank.path, Some(cache.join("datui.log")));
737        assert_eq!(blank.level, LevelFilter::Warn);
738    }
739
740    #[test]
741    fn off_means_no_file_and_a_typo_means_the_default() {
742        let off = LogSettings::resolve(None, Some("OFF"), Some(Path::new("/c")));
743        assert_eq!(off.path, None);
744
745        let typo = LogSettings::resolve(None, Some("loud"), Some(Path::new("/c")));
746        assert_eq!(typo.level, LevelFilter::Warn);
747        assert_eq!(typo.unknown_level.as_deref(), Some("loud"));
748        assert!(typo.path.is_some());
749    }
750
751    #[test]
752    fn a_full_log_moves_aside_once() {
753        let dir = tempfile::tempdir().unwrap();
754        let path = dir.path().join("sub").join("datui.log");
755        let mut log = FileLog::open(&path, 100).unwrap();
756        let line = "x".repeat(60);
757        assert!(!log.write_line(&line).unwrap());
758        assert!(log.write_line(&line).unwrap(), "past the cap it rotates");
759        assert!(rotated_path(&path).exists());
760        assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
761        // A second rotation replaces the first: one old file, never more.
762        log.write_line(&"y".repeat(120)).unwrap();
763        let old = std::fs::read_to_string(rotated_path(&path)).unwrap();
764        assert!(old.starts_with('y'), "{old:?}");
765        assert!(!dir.path().join("sub").join("datui.log.1.1").exists());
766    }
767
768    /// Two sessions share the log. One moves it aside; the other, still writing into
769    /// the moved file, must not then move the first one's fresh log over it.
770    #[test]
771    fn two_sessions_never_rotate_each_others_lines_away() {
772        let dir = tempfile::tempdir().unwrap();
773        let path = dir.path().join("datui.log");
774        let mut a = FileLog::open(&path, 100).unwrap();
775        let mut b = FileLog::open(&path, 100).unwrap();
776        let mut written = Vec::new();
777        // Under two caps' worth in all: one rotation, so nothing may be lost.
778        for n in 0..2 {
779            for (who, log) in [("a", &mut a), ("b", &mut b)] {
780                let line = format!("{who}{n} {}", "x".repeat(40));
781                log.write_line(&line).unwrap();
782                written.push(line);
783            }
784        }
785        let all = std::fs::read_to_string(rotated_path(&path)).unwrap_or_default()
786            + &std::fs::read_to_string(&path).unwrap();
787        for line in &written {
788            assert!(all.contains(line.as_str()), "lost {line:?} from {all:?}");
789        }
790    }
791
792    #[test]
793    fn credentials_are_masked() {
794        let secrets = vec!["wJalrXUtnFEMI/K7MDENG".to_string()];
795        let cases = [
796            ("key wJalrXUtnFEMI/K7MDENG refused", "wJalrXUtnFEMI"),
797            ("GET https://alice:hunter22@host/x failed", "hunter22"),
798            (
799                "403 for https://b.s3.amazonaws.com/k?X-Amz-Credential=AKIA123%2F&X-Amz-Signature=abcdef12",
800                "abcdef12",
801            ),
802            (
803                "https://acct.blob.core.windows.net/c?sv=2022&sig=Zm9vYmFy",
804                "Zm9vYmFy",
805            ),
806            ("Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.x.y", "eyJhbGci"),
807            (
808                "DefaultEndpointsProtocol=https;AccountKey=c2VjcmV0a2V5;",
809                "c2VjcmV0a2V5",
810            ),
811        ];
812        for (line, secret) in cases {
813            let masked = redact(line, &secrets);
814            assert!(!masked.contains(secret), "{line} -> {masked}");
815            assert!(masked.contains("***"), "{masked}");
816        }
817        assert_eq!(
818            redact("s3://bucket/key.parquet: not found", &secrets),
819            "s3://bucket/key.parquet: not found"
820        );
821        for plain in [
822            "basic statistics failed for column_with_long_name",
823            "abfss://container@account.dfs.core.windows.net/data.parquet",
824        ] {
825            assert_eq!(redact(plain, &secrets), plain);
826        }
827        assert!(!redact("Basic YWxpY2U6aHVudGVyMg==", &secrets).contains("YWxpY2U6"));
828        assert!(!redact("sig=Zm9vYmFyYmF6&sv=2022", &secrets).contains("Zm9vYmFy"));
829    }
830
831    #[test]
832    fn a_variable_is_masked_by_its_name_but_not_when_it_names_a_place() {
833        let key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
834        for name in [
835            "AWS_SECRET_ACCESS_KEY",
836            "AWS_SESSION_TOKEN",
837            "AZURE_STORAGE_ACCOUNT_KEY",
838            "AZURE_STORAGE_CONNECTION_STRING",
839            "MINIO_ROOT_PASSWORD",
840            "hf_token",
841            "OPENAI_API_KEY",
842        ] {
843            assert!(holds_a_credential(name, key), "{name}");
844        }
845        for name in [
846            "AWS_WEB_IDENTITY_TOKEN_FILE",
847            "GITHUB_TOKEN_PATH",
848            "PASSWORD_STORE_DIR",
849            "VAULT_TOKEN_URL",
850        ] {
851            assert!(!holds_a_credential(name, key), "{name}");
852        }
853        assert!(!holds_a_credential("AWS_REGION", key));
854        let dir = tempfile::tempdir().unwrap();
855        let place = dir.path().to_string_lossy();
856        assert!(
857            !holds_a_credential("SOME_SECRET", &place),
858            "a path that exists is not the secret"
859        );
860    }
861
862    #[test]
863    fn a_caught_panic_becomes_a_message() {
864        assert_eq!(catch_panic(|| 7), Ok(7));
865        let message = catch_panic::<()>(|| panic!("worker died")).unwrap_err();
866        assert!(
867            message.starts_with("Internal error: worker died"),
868            "{message}"
869        );
870        let formatted = catch_panic::<()>(|| panic!("row {} of {}", 3, 9)).unwrap_err();
871        assert!(formatted.contains("row 3 of 9"), "{formatted}");
872    }
873
874    /// The only test in this binary that sets the global logger and the Polars hook, so
875    /// nothing else races it for the file.
876    #[test]
877    fn a_polars_warning_lands_in_the_log_once_and_a_user_warning_is_queued() {
878        let dir = tempfile::tempdir().unwrap();
879        let path = dir.path().join("datui.log");
880        init(&LogSettings {
881            path: Some(path.clone()),
882            level: LevelFilter::Warn,
883            unknown_level: None,
884        });
885        assert_eq!(current_path(), Some(path.clone()));
886
887        for _ in 0..3 {
888            polars_error::polars_warn!(
889                Deprecation,
890                "casting in test {} is deprecated.\nUse something else.",
891                std::process::id()
892            );
893        }
894        polars_error::polars_warn!(UserWarning, "remapped categories in test {}", 7);
895        log::info!("below the level");
896        log::logger().flush();
897
898        let text = std::fs::read_to_string(&path).unwrap();
899        let deprecation = format!(
900            "Deprecation: casting in test {} is deprecated. Use something else.",
901            std::process::id()
902        );
903        assert_eq!(text.matches(&deprecation).count(), 1, "{text}");
904        assert!(
905            text.contains("UserWarning: remapped categories in test 7"),
906            "{text}"
907        );
908        assert!(!text.contains("below the level"), "{text}");
909
910        // The user warning reaches the control bar, once, when the bar is free.
911        let (tx, _rx) = std::sync::mpsc::channel();
912        let mut app = crate::App::new(tx, crate::tests::test_runtime());
913        app.busy = true;
914        assert!(!app.flash_polars_warning(), "a busy message outranks it");
915        app.busy = false;
916        app.error_modal.active = true;
917        assert!(!app.flash_polars_warning(), "a modal would hide it");
918        app.error_modal.active = false;
919        let mut flashed = Vec::new();
920        while app.flash_polars_warning() {
921            flashed.extend(app.flash_message().map(str::to_string));
922            app.flash = None;
923        }
924        assert!(
925            flashed.contains(&"Polars: remapped categories in test 7".to_string()),
926            "{flashed:?}"
927        );
928        assert!(!flashed.iter().any(|w| w.contains("casting in test")));
929        polars_error::polars_warn!(UserWarning, "remapped categories in test {}", 7);
930        assert!(
931            std::iter::from_fn(next_polars_warning).all(|w| !w.contains("in test 7")),
932            "a user warning is flashed once"
933        );
934    }
935}