Skip to main content

par_term/session_logger/
core.rs

1//! Core [`SessionLogger`] struct, state management, and recording methods.
2//!
3//! See the [module-level documentation](super) for information about security
4//! considerations and password redaction.
5//!
6//! Format-specific finalization (HTML headers/footers, asciicast serialization)
7//! lives in `super::format_writers`.
8
9use crate::config::SessionLogFormat;
10use crate::session_logger::writers::{html_escape, strip_ansi_escapes};
11use anyhow::{Context, Result};
12use chrono::{Local, Utc};
13use par_term_emu_core_rust::terminal::{RecordingEvent, RecordingEventType, RecordingSession};
14use parking_lot::Mutex;
15use std::fs::{File, OpenOptions};
16use std::io::{BufWriter, Write};
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19
20/// Marker text written to the log when input is redacted during a password prompt.
21pub(super) const REDACTION_MARKER: &str = "[INPUT REDACTED - echo off]";
22
23/// Serializes a recording through the terminal's graphics-aware v3 export;
24/// `None` when the terminal is unavailable, which finalization treats as
25/// text-only v3.
26pub(super) type V3Exporter = Box<dyn Fn(&RecordingSession) -> Option<String> + Send>;
27
28/// Common password prompt patterns (case-insensitive matching).
29///
30/// These patterns are matched against terminal output (after stripping ANSI
31/// escape sequences) to detect when the user is being asked for a password.
32/// The match is performed on the last line of each output chunk.
33///
34/// SEC-009: Includes patterns for common non-English password prompts to reduce
35/// redaction gaps for internationalized systems and multilingual users.
36pub(super) const PASSWORD_PROMPT_PATTERNS: &[&str] = &[
37    "password:",
38    "password for",
39    "passwd:",
40    "[sudo]",
41    "passphrase:",
42    "passphrase for",
43    "enter pin",
44    "enter passphrase",
45    "enter password",
46    "old password:",
47    "new password:",
48    "retype password:",
49    "confirm password:",
50    "current password:",
51    "verification code:",
52    "login password:",
53    "ldap password:",
54    "key password:",
55    "decryption password:",
56    "encryption password:",
57    "(current) unix password:",
58    "token:",
59    // Two-factor / MFA prompts
60    "authenticator code:",
61    "2fa code:",
62    "otp:",
63    "one-time password:",
64    "one time password:",
65    "security code:",
66    "totp:",
67    // API key / secret prompts (interactive tools that prompt for credentials)
68    "api key:",
69    "api secret:",
70    "secret key:",
71    "access key:",
72    "access token:",
73    "secret token:",
74    "private key:",
75    "client secret:",
76    "auth token:",
77    "bearer token:",
78    // SSH / GPG passphrases
79    "enter passphrase for key",
80    "key passphrase:",
81    "gpg passphrase:",
82    // Database / service credential prompts
83    "db password:",
84    "database password:",
85    "mysql password:",
86    "postgres password:",
87    "redis password:",
88    // Cloud / vault prompts
89    "vault token:",
90    "vault password:",
91    "aws secret",
92    "azure secret",
93    // SEC-009: Non-English password prompt patterns.
94    // Covers Portuguese, Spanish, French, Russian, German, Japanese, Korean,
95    // Chinese, Italian, Dutch, Polish, Turkish, Hindi, Arabic, and Hebrew.
96    // All matched case-insensitively.
97    // Portuguese
98    "senha:",
99    "digite a senha",
100    "informe a senha",
101    "senha atual:",
102    "nova senha:",
103    "confirme a senha",
104    // Spanish
105    "contrase\u{00f1}a:", // contraseña:
106    "contrasena:",        // contrasena: (ASCII fallback)
107    "introduzca la contrase\u{00f1}a",
108    "contrase\u{00f1}a actual:",
109    "nueva contrase\u{00f1}a:",
110    "confirme la contrase\u{00f1}a",
111    // French
112    "mot de passe:",
113    "entrez le mot de passe",
114    "mot de passe actuel:",
115    "nouveau mot de passe:",
116    "confirmez le mot de passe",
117    // Russian
118    "\u{043f}\u{0430}\u{0440}\u{043e}\u{043b}\u{044c}:", // пароль:
119    "\u{0432}\u{0432}\u{0435}\u{0434}\u{0438}\u{0442}\u{0435} \u{043f}\u{0430}\u{0440}\u{043e}\u{043b}\u{044c}", // введите пароль
120    "\u{043d}\u{043e}\u{0432}\u{044b}\u{0439} \u{043f}\u{0430}\u{0440}\u{043e}\u{043b}\u{044c}:", // новый пароль:
121    // German
122    "passwort:",
123    "geben sie das passwort",
124    "passwort eingeben",
125    "neues passwort:",
126    "passwort best\u{00e4}tigen", // passwort bestätigen
127    // Japanese
128    "\u{30d1}\u{30b9}\u{30ef}\u{30fc}\u{30c9}:", // パスワード:
129    "\u{30d1}\u{30b9}\u{30ef}\u{30fc}\u{30c9}\u{5165}\u{529b}", // パスワード入力
130    // Korean
131    "암호:",     // 암호:
132    "비밀번호:", // 비밀번호:
133    // Chinese (Simplified)
134    "密码:",      // 密码:
135    "请输入密码", // 请输入密码
136    // Italian
137    "password:", // (already covered by English "password:" — case-insensitive)
138    "inserire la password",
139    "nuova password:",
140    // Dutch
141    "wachtwoord:",
142    "voer het wachtwoord",
143    "nieuw wachtwoord:",
144    // Polish
145    "has\u{0142}o:",         // hasło:
146    "wprowadź has\u{0142}o", // wprowadź hasło
147    "nowe has\u{0142}o:",    // nowe hasło:
148    // Turkish
149    "ş}ifre:", // şifre: (note: also covered by lowercase match of "Şifre:")
150    "parola:",
151    // Hindi
152    "प}ासवर्द}:", // पासवर्ड: (Hindi often uses English loanword)
153    // Arabic
154    "ك}لمة ا}لمرور}:", // كلمة المرور:
155    // Hebrew
156    "ס}יסמא}:", // סיסמא:
157];
158
159/// Sensitive output line heuristics (case-insensitive substring matching).
160///
161/// SEC-006: These patterns detect terminal *output* lines that are likely to
162/// contain sensitive values being printed (e.g. `export API_KEY=abc123`,
163/// `aws_secret_access_key = ...`). When a line matches, the logger emits a
164/// warning comment in the log rather than the raw content.
165///
166/// Unlike password prompt detection (which suppresses subsequent *input*),
167/// these patterns trigger on *output* data — they redact or annotate the
168/// output line itself.
169///
170/// # Limitations
171///
172/// This is a heuristic. It cannot catch all forms of sensitive output (e.g.
173/// values printed without a recognisable key name, or base64-encoded secrets).
174/// Users who regularly work with credentials in the terminal should disable
175/// session logging for those sessions.
176pub(super) const SENSITIVE_OUTPUT_PATTERNS: &[&str] = &[
177    // Shell variable export of credential-like names
178    "export aws_access_key",
179    "export aws_secret",
180    "export api_key",
181    "export api_secret",
182    "export auth_token",
183    "export access_token",
184    "export secret_key",
185    "export private_key",
186    "export client_secret",
187    "export database_url",
188    "export db_password",
189    "export gh_token",
190    "export github_token",
191    "export gitlab_token",
192    "export npm_token",
193    "export pypi_token",
194    // AWS credential file / env output patterns
195    "aws_access_key_id",
196    "aws_secret_access_key",
197    "aws_session_token",
198    // Generic key=value patterns for common secret variable names
199    "api_key=",
200    "apikey=",
201    "api_secret=",
202    "access_token=",
203    "auth_token=",
204    "secret_key=",
205    "private_key=",
206    "client_secret=",
207    // CI/CD and hosting service tokens
208    "github_token=",
209    "gh_token=",
210    "heroku_api_key=",
211    "heroku_api_token=",
212    "npm_token=",
213    "pypi_token=",
214    "gitlab_token=",
215    "circleci_token=",
216    // HTTP Authorization Bearer tokens (e.g. from curl -v output or httpie)
217    "bearer ",
218    // .env file content echoed to terminal
219    "dotenv",
220    // Token/key output from CLI tools
221    "-----begin rsa private key-----",
222    "-----begin ec private key-----",
223    "-----begin openssh private key-----",
224    "-----begin pgp private key block-----",
225];
226
227/// Session logger that records terminal output to files.
228///
229/// The logger captures PTY output with timestamps and can export
230/// to multiple formats. It uses buffered writes for performance.
231///
232/// See the [module-level documentation](super) for information about
233/// sensitive data filtering.
234pub struct SessionLogger {
235    /// Whether logging is currently active
236    pub(super) active: bool,
237    /// The log format to use
238    pub(super) format: SessionLogFormat,
239    /// Output file path
240    pub(super) output_path: PathBuf,
241    /// Buffered writer for the log file
242    pub(super) writer: Option<BufWriter<File>>,
243    /// Recording session data (for asciicast format)
244    pub(super) recording: Option<RecordingSession>,
245    /// Recording start time (for relative timestamps)
246    pub(super) start_time: std::time::Instant,
247    /// Terminal dimensions
248    pub(super) dimensions: (usize, usize),
249    /// Session title
250    pub(super) title: Option<String>,
251    /// Whether password redaction is enabled (heuristic prompt detection)
252    pub(super) redact_passwords: bool,
253    /// First write error seen since logging started, if any.
254    ///
255    /// A transcript that stops growing mid-session (disk full, log file deleted)
256    /// is indistinguishable from an idle one, so the failure is latched here and
257    /// reported through [`SessionLogger::is_active`] rather than discarded.
258    pub(super) write_error: Option<String>,
259    /// Whether the logger has detected a password prompt in recent output
260    /// and is currently suppressing input recording.
261    pub(super) password_prompt_active: bool,
262    /// Whether echo is externally known to be suppressed (e.g., PTY echo off).
263    /// When true, input is always redacted regardless of prompt detection.
264    pub(super) echo_suppressed: bool,
265    /// Whether a redaction marker has already been emitted for the current
266    /// suppression period (to avoid flooding the log with repeated markers).
267    pub(super) redaction_marker_emitted: bool,
268    /// Graphics-aware v3 exporter wired by the owning Tab: serializes the
269    /// recording through the terminal (whose graphics store is crate-internal
270    /// to emu-core); returns None when the terminal is unavailable, which
271    /// finalization treats as text-only v3.
272    pub(super) v3_exporter: Option<V3Exporter>,
273}
274
275impl SessionLogger {
276    /// Create a new session logger.
277    ///
278    /// # Arguments
279    /// * `format` - The output format to use
280    /// * `log_dir` - Directory where log files are stored
281    /// * `dimensions` - Terminal dimensions (cols, rows)
282    /// * `title` - Optional session title
283    pub fn new(
284        format: SessionLogFormat,
285        log_dir: &Path,
286        dimensions: (usize, usize),
287        title: Option<String>,
288    ) -> Result<Self> {
289        // Generate filename with timestamp
290        let timestamp = Local::now().format("%Y%m%d_%H%M%S");
291        let filename = format!("session_{}.{}", timestamp, format.extension());
292        let output_path = log_dir.join(filename);
293
294        log::info!(
295            "Creating session logger: {:?} (format: {:?})",
296            output_path,
297            format
298        );
299
300        // Create the log file with restrictive permissions (owner read/write only)
301        // On Unix, use mode 0o600 to prevent world-readable session logs
302        // On Windows, file permissions work differently but this is still safe
303        let mut opts = OpenOptions::new();
304        opts.write(true).create(true).truncate(true);
305        #[cfg(unix)]
306        {
307            use std::os::unix::fs::OpenOptionsExt;
308            opts.mode(0o600);
309        }
310        let file = opts
311            .open(&output_path)
312            .with_context(|| format!("Failed to create session log file: {:?}", output_path))?;
313        let writer = BufWriter::with_capacity(8192, file); // 8KB buffer
314
315        // Initialize recording session for asciicast formats
316        let recording = if matches!(
317            format,
318            SessionLogFormat::Asciicast | SessionLogFormat::AsciicastV3
319        ) {
320            let mut env = std::collections::HashMap::new();
321            env.insert("TERM".to_string(), "xterm-256color".to_string());
322            env.insert("COLS".to_string(), dimensions.0.to_string());
323            env.insert("ROWS".to_string(), dimensions.1.to_string());
324
325            Some(RecordingSession {
326                id: uuid::Uuid::new_v4().to_string(),
327                created_at: Utc::now().timestamp_millis() as u64,
328                initial_size: dimensions,
329                env,
330                events: Vec::new(),
331                duration: 0,
332                title: title
333                    .clone()
334                    .unwrap_or_else(|| "Terminal Recording".to_string()),
335            })
336        } else {
337            None
338        };
339
340        Ok(Self {
341            active: false,
342            format,
343            output_path,
344            writer: Some(writer),
345            recording,
346            start_time: std::time::Instant::now(),
347            dimensions,
348            title,
349            redact_passwords: true, // Enabled by default for safety
350            write_error: None,
351            password_prompt_active: false,
352            echo_suppressed: false,
353            redaction_marker_emitted: false,
354            v3_exporter: None,
355        })
356    }
357
358    /// Start logging.
359    pub fn start(&mut self) -> Result<()> {
360        if self.active {
361            return Ok(());
362        }
363
364        // A logger stopped by a write failure keeps the same unwritable file
365        // handle, so restarting it would fail again on the next byte. Surface the
366        // original error instead of pretending the restart worked.
367        if let Some(ref e) = self.write_error {
368            return Err(anyhow::anyhow!("session log is not writable: {}", e));
369        }
370
371        self.active = true;
372        self.start_time = std::time::Instant::now();
373
374        // Re-anchor the recording's epoch start to the same moment the
375        // monotonic event timeline starts: the v3 exporter derives graphic
376        // times as added_at - created_at, so keeping the construction stamp
377        // would offset every graphic by the construct-to-start gap.
378        if let Some(ref mut recording) = self.recording {
379            recording.created_at = Utc::now().timestamp_millis() as u64;
380        }
381
382        // Write format-specific header / startup comment.
383        match self.format {
384            SessionLogFormat::Html => {
385                self.write_html_header()?;
386            }
387            SessionLogFormat::Plain => {
388                // SEC-004: Write a startup warning so readers of the log file are
389                // aware of the redaction limitations. This comment appears at the
390                // top of every plain-text session log.
391                self.write_plain_redaction_warning()?;
392            }
393            SessionLogFormat::Asciicast | SessionLogFormat::AsciicastV3 => {
394                // Asciicast formats: the header is written during finalization.
395                // No startup banner is added here; warnings are in the log file
396                // at the application level via log::warn!.
397            }
398        }
399
400        log::info!("Session logging started: {:?}", self.output_path);
401        Ok(())
402    }
403
404    /// Stop logging and finalize the log file.
405    pub fn stop(&mut self) -> Result<PathBuf> {
406        if !self.active {
407            return Ok(self.output_path.clone());
408        }
409
410        self.active = false;
411
412        // Finalize based on format (delegates to format_writers module)
413        super::format_writers::finalize_format(self)?;
414
415        // Flush and close the writer
416        if let Some(mut writer) = self.writer.take() {
417            writer
418                .flush()
419                .with_context(|| format!("Failed to flush session log: {:?}", self.output_path))?;
420        }
421
422        log::info!("Session logging stopped: {:?}", self.output_path);
423        Ok(self.output_path.clone())
424    }
425
426    /// Enable or disable password prompt detection and input redaction.
427    ///
428    /// When enabled, the logger monitors terminal output for common password
429    /// prompt patterns and suppresses input recording during password entry.
430    /// Enabled by default.
431    pub fn set_redact_passwords(&mut self, enabled: bool) {
432        self.redact_passwords = enabled;
433    }
434
435    /// Check whether password redaction is enabled.
436    pub fn redact_passwords(&self) -> bool {
437        self.redact_passwords
438    }
439
440    /// Wire the graphics-aware v3 exporter used at finalization (see
441    /// `write_asciicast_v3`). Harmless to set on non-v3 formats: the exporter
442    /// is only consulted when a v3 recording is serialized. Returning None
443    /// from the exporter falls back to text-only v3.
444    pub fn set_v3_exporter(&mut self, exporter: V3Exporter) {
445        self.v3_exporter = Some(exporter);
446    }
447
448    /// Externally signal that echo is suppressed (e.g., PTY echo off).
449    ///
450    /// When set to `true`, all input recording is suppressed regardless of
451    /// prompt detection. This is useful when the caller has access to the
452    /// terminal's echo mode state.
453    ///
454    /// Call with `false` when echo is re-enabled.
455    pub fn set_echo_suppressed(&mut self, suppressed: bool) {
456        if self.echo_suppressed != suppressed {
457            self.echo_suppressed = suppressed;
458            if !suppressed {
459                // Echo re-enabled; reset the marker flag so a new redaction
460                // period will emit a fresh marker.
461                self.redaction_marker_emitted = false;
462            }
463        }
464    }
465
466    /// Check whether echo is externally marked as suppressed.
467    pub fn echo_suppressed(&self) -> bool {
468        self.echo_suppressed
469    }
470
471    /// Record output data from the terminal.
472    pub fn record_output(&mut self, data: &[u8]) {
473        if !self.active {
474            return;
475        }
476
477        // Check for password prompts in the output if redaction is enabled.
478        // This must run before filtering so that subsequent input is suppressed.
479        if self.redact_passwords {
480            self.check_for_password_prompt(data);
481        }
482
483        // SEC-006: Check for sensitive credential output patterns line-by-line.
484        // Lines that match known sensitive-data heuristics are replaced with a
485        // redaction annotation before writing to the log.
486        let data_to_write: Vec<u8> = if self.redact_passwords {
487            self.filter_sensitive_output(data)
488        } else {
489            data.to_vec()
490        };
491        let data = data_to_write.as_slice();
492
493        let elapsed = self.start_time.elapsed().as_millis() as u64;
494
495        match self.format {
496            SessionLogFormat::Plain => {
497                // Strip ANSI escape sequences and write plain text
498                let text = strip_ansi_escapes(data);
499                self.write_bytes(text.as_bytes());
500            }
501            SessionLogFormat::Html => {
502                // Convert to HTML (basic escaping for now)
503                let text = String::from_utf8_lossy(data);
504                let escaped = html_escape(&text);
505                self.write_bytes(escaped.as_bytes());
506            }
507            SessionLogFormat::Asciicast | SessionLogFormat::AsciicastV3 => {
508                // Asciicast formats buffer events; written during finalization.
509                if let Some(ref mut recording) = self.recording {
510                    recording.events.push(RecordingEvent {
511                        timestamp: elapsed,
512                        event_type: RecordingEventType::Output,
513                        data: data.to_vec(),
514                        metadata: None,
515                    });
516                    recording.duration = elapsed;
517                }
518            }
519        }
520    }
521
522    /// Record input data (keyboard input).
523    ///
524    /// When password redaction is active (either via prompt detection or
525    /// explicit echo suppression), input data is replaced with a redaction
526    /// marker. A newline or carriage return in the input data signals the
527    /// end of a password entry, clearing the prompt-detected suppression.
528    pub fn record_input(&mut self, data: &[u8]) {
529        if !self.active {
530            return;
531        }
532
533        let is_suppressed = self.echo_suppressed || self.password_prompt_active;
534
535        if is_suppressed && self.redact_passwords {
536            // Check if input contains a newline (password entry complete)
537            let has_newline = data.iter().any(|&b| b == b'\n' || b == b'\r');
538
539            // Emit a single redaction marker per suppression period
540            if !self.redaction_marker_emitted {
541                self.emit_redaction_marker();
542                self.redaction_marker_emitted = true;
543            }
544
545            if has_newline {
546                // Password entry complete; clear prompt-based suppression.
547                // (echo_suppressed is managed externally and not cleared here.)
548                self.password_prompt_active = false;
549                self.redaction_marker_emitted = false;
550            }
551            return;
552        }
553
554        // Only asciicast records input
555        if matches!(
556            self.format,
557            SessionLogFormat::Asciicast | SessionLogFormat::AsciicastV3
558        ) {
559            let elapsed = self.start_time.elapsed().as_millis() as u64;
560            if let Some(ref mut recording) = self.recording {
561                recording.events.push(RecordingEvent {
562                    timestamp: elapsed,
563                    event_type: RecordingEventType::Input,
564                    data: data.to_vec(),
565                    metadata: None,
566                });
567                recording.duration = elapsed;
568            }
569        }
570    }
571
572    /// Record a terminal resize event.
573    pub fn record_resize(&mut self, cols: usize, rows: usize) {
574        if !self.active {
575            return;
576        }
577
578        self.dimensions = (cols, rows);
579
580        // Only asciicast records resize events
581        if matches!(
582            self.format,
583            SessionLogFormat::Asciicast | SessionLogFormat::AsciicastV3
584        ) {
585            let elapsed = self.start_time.elapsed().as_millis() as u64;
586            if let Some(ref mut recording) = self.recording {
587                recording.events.push(RecordingEvent {
588                    timestamp: elapsed,
589                    event_type: RecordingEventType::Resize,
590                    data: Vec::new(),
591                    metadata: Some((cols, rows)),
592                });
593                recording.duration = elapsed;
594            }
595        }
596    }
597
598    /// Check if logging is active.
599    ///
600    /// A write failure clears `active`, so this reflects writer health and not
601    /// just the user's last toggle: a transcript that stopped growing mid-session
602    /// must not keep reporting itself as being recorded.
603    pub fn is_active(&self) -> bool {
604        self.active
605    }
606
607    /// The write failure that stopped logging, if any.
608    pub fn write_error(&self) -> Option<&str> {
609        self.write_error.as_deref()
610    }
611
612    /// Write to the log file, stopping logging on the first failure.
613    ///
614    /// Logging is not retried: once the file is gone or the disk is full every
615    /// subsequent write fails too, and the transcript already has a hole in it.
616    fn write_bytes(&mut self, bytes: &[u8]) {
617        let Some(ref mut writer) = self.writer else {
618            return;
619        };
620        if let Err(e) = writer.write_all(bytes) {
621            let msg = e.to_string();
622            log::error!(
623                "Session log write failed for {:?}: {} — logging stopped",
624                self.output_path,
625                msg
626            );
627            self.write_error = Some(msg);
628            self.active = false;
629        }
630    }
631
632    /// SEC-006: Check whether a single stripped line matches any sensitive
633    /// output heuristic.
634    ///
635    /// Exposed as `pub(super)` for unit-test access from `tests.rs`.
636    pub(super) fn line_is_sensitive(line: &str) -> bool {
637        let lower = line.to_ascii_lowercase();
638        SENSITIVE_OUTPUT_PATTERNS.iter().any(|p| lower.contains(p))
639    }
640
641    /// Get the output path.
642    pub fn output_path(&self) -> &PathBuf {
643        &self.output_path
644    }
645
646    /// Flush buffered data to disk.
647    pub fn flush(&mut self) -> Result<()> {
648        if let Some(ref mut writer) = self.writer {
649            writer
650                .flush()
651                .with_context(|| format!("Failed to flush session log: {:?}", self.output_path))?;
652        }
653        Ok(())
654    }
655
656    // === Private helper methods ===
657
658    /// SEC-006: Filter sensitive credential patterns from raw PTY output.
659    ///
660    /// Each line in `data` is stripped of ANSI escapes and checked against
661    /// [`SENSITIVE_OUTPUT_PATTERNS`]. Lines that match are replaced with a
662    /// `[OUTPUT REDACTED - sensitive data heuristic]` marker in the returned
663    /// byte vector. Non-matching lines are passed through unchanged (using the
664    /// *original* bytes, including ANSI codes, so that HTML/asciicast formats
665    /// preserve colour).
666    ///
667    /// The check operates on ANSI-stripped content (case-insensitive) to
668    /// avoid coloured prompt text defeating the heuristic.
669    fn filter_sensitive_output(&self, data: &[u8]) -> Vec<u8> {
670        const SENSITIVE_OUTPUT_MARKER: &str =
671            "[OUTPUT REDACTED - sensitive data heuristic matched]\n";
672
673        // Fast path: strip ANSI from the whole chunk and check if any line
674        // could be sensitive. If not, return the original bytes unchanged.
675        let stripped = strip_ansi_escapes(data);
676        let any_sensitive = stripped.lines().any(Self::line_is_sensitive);
677        if !any_sensitive {
678            return data.to_vec();
679        }
680
681        // Slow path: reconstruct the output, replacing sensitive lines.
682        // We work on the ANSI-stripped text for the per-line decision but
683        // need to write back sanitised content; since the raw bytes may have
684        // multi-byte ANSI sequences that don't align 1:1 with stripped lines,
685        // we rebuild the output from the stripped text (forgoing colour for
686        // the filtered chunk — acceptable given the security trade-off).
687        let mut result = Vec::with_capacity(data.len());
688        for line in stripped.lines() {
689            if Self::line_is_sensitive(line) {
690                result.extend_from_slice(SENSITIVE_OUTPUT_MARKER.as_bytes());
691            } else {
692                result.extend_from_slice(line.as_bytes());
693                result.push(b'\n');
694            }
695        }
696        // Preserve trailing newline status from original stripped text.
697        if !stripped.ends_with('\n') && result.last() == Some(&b'\n') {
698            result.pop();
699        }
700        result
701    }
702
703    /// Check terminal output for password prompt patterns.
704    ///
705    /// Examines the last line of the output data (after stripping ANSI escape
706    /// sequences) for common password prompt patterns. If a match is found,
707    /// sets `password_prompt_active` to suppress input recording.
708    fn check_for_password_prompt(&mut self, data: &[u8]) {
709        let text = strip_ansi_escapes(data);
710        // Get the last non-empty line (prompts are typically the last thing printed)
711        let last_line = text
712            .lines()
713            .rev()
714            .find(|line| !line.trim().is_empty())
715            .unwrap_or("")
716            .to_ascii_lowercase();
717
718        if last_line.is_empty() {
719            return;
720        }
721
722        if PASSWORD_PROMPT_PATTERNS
723            .iter()
724            .any(|pattern| last_line.contains(pattern))
725            && !self.password_prompt_active
726        {
727            self.password_prompt_active = true;
728            self.redaction_marker_emitted = false;
729        }
730    }
731
732    /// Emit a redaction marker into the recording/log.
733    fn emit_redaction_marker(&mut self) {
734        if matches!(
735            self.format,
736            SessionLogFormat::Asciicast | SessionLogFormat::AsciicastV3
737        ) {
738            let elapsed = self.start_time.elapsed().as_millis() as u64;
739            if let Some(ref mut recording) = self.recording {
740                recording.events.push(RecordingEvent {
741                    timestamp: elapsed,
742                    event_type: RecordingEventType::Input,
743                    data: REDACTION_MARKER.as_bytes().to_vec(),
744                    metadata: None,
745                });
746                recording.duration = elapsed;
747            }
748        }
749    }
750}
751
752impl Drop for SessionLogger {
753    fn drop(&mut self) {
754        if self.active {
755            let _ = self.stop();
756        }
757    }
758}
759
760/// Thread-safe wrapper for SessionLogger.
761///
762/// Uses `parking_lot::Mutex` because all access is from sync contexts (the winit
763/// event loop and background std threads). The non-async, non-poisoning API of
764/// `parking_lot` is sufficient and avoids the overhead of `tokio::sync::Mutex`.
765pub type SharedSessionLogger = Arc<Mutex<Option<SessionLogger>>>;
766
767/// Create a new shared session logger.
768pub fn create_shared_logger() -> SharedSessionLogger {
769    Arc::new(Mutex::new(None))
770}