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