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}