use crate::config::SessionLogFormat;
use crate::session_logger::writers::{html_escape, strip_ansi_escapes};
use anyhow::{Context, Result};
use chrono::{Local, Utc};
use par_term_emu_core_rust::terminal::{RecordingEvent, RecordingEventType, RecordingSession};
use parking_lot::Mutex;
use std::fs::{File, OpenOptions};
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub(super) const REDACTION_MARKER: &str = "[INPUT REDACTED - echo off]";
pub(super) const PASSWORD_PROMPT_PATTERNS: &[&str] = &[
"password:",
"password for",
"passwd:",
"[sudo]",
"passphrase:",
"passphrase for",
"enter pin",
"enter passphrase",
"enter password",
"old password:",
"new password:",
"retype password:",
"confirm password:",
"current password:",
"verification code:",
"login password:",
"ldap password:",
"key password:",
"decryption password:",
"encryption password:",
"(current) unix password:",
"token:",
"authenticator code:",
"2fa code:",
"otp:",
"one-time password:",
"one time password:",
"security code:",
"totp:",
"api key:",
"api secret:",
"secret key:",
"access key:",
"access token:",
"secret token:",
"private key:",
"client secret:",
"auth token:",
"bearer token:",
"enter passphrase for key",
"key passphrase:",
"gpg passphrase:",
"db password:",
"database password:",
"mysql password:",
"postgres password:",
"redis password:",
"vault token:",
"vault password:",
"aws secret",
"azure secret",
];
pub(super) const SENSITIVE_OUTPUT_PATTERNS: &[&str] = &[
"export aws_access_key",
"export aws_secret",
"export api_key",
"export api_secret",
"export auth_token",
"export access_token",
"export secret_key",
"export private_key",
"export client_secret",
"export database_url",
"export db_password",
"export gh_token",
"export github_token",
"export gitlab_token",
"export npm_token",
"export pypi_token",
"aws_access_key_id",
"aws_secret_access_key",
"aws_session_token",
"api_key=",
"apikey=",
"api_secret=",
"access_token=",
"auth_token=",
"secret_key=",
"private_key=",
"client_secret=",
"github_token=",
"gh_token=",
"heroku_api_key=",
"heroku_api_token=",
"npm_token=",
"pypi_token=",
"gitlab_token=",
"circleci_token=",
"bearer ",
"dotenv",
"-----begin rsa private key-----",
"-----begin ec private key-----",
"-----begin openssh private key-----",
"-----begin pgp private key block-----",
];
pub struct SessionLogger {
pub(super) active: bool,
pub(super) format: SessionLogFormat,
pub(super) output_path: PathBuf,
pub(super) writer: Option<BufWriter<File>>,
pub(super) recording: Option<RecordingSession>,
pub(super) start_time: std::time::Instant,
pub(super) dimensions: (usize, usize),
pub(super) title: Option<String>,
pub(super) redact_passwords: bool,
pub(super) password_prompt_active: bool,
pub(super) echo_suppressed: bool,
pub(super) redaction_marker_emitted: bool,
}
impl SessionLogger {
pub fn new(
format: SessionLogFormat,
log_dir: &Path,
dimensions: (usize, usize),
title: Option<String>,
) -> Result<Self> {
let timestamp = Local::now().format("%Y%m%d_%H%M%S");
let filename = format!("session_{}.{}", timestamp, format.extension());
let output_path = log_dir.join(filename);
log::info!(
"Creating session logger: {:?} (format: {:?})",
output_path,
format
);
let mut opts = OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let file = opts
.open(&output_path)
.with_context(|| format!("Failed to create session log file: {:?}", output_path))?;
let writer = BufWriter::with_capacity(8192, file);
let recording = if format == SessionLogFormat::Asciicast {
let mut env = std::collections::HashMap::new();
env.insert("TERM".to_string(), "xterm-256color".to_string());
env.insert("COLS".to_string(), dimensions.0.to_string());
env.insert("ROWS".to_string(), dimensions.1.to_string());
Some(RecordingSession {
id: uuid::Uuid::new_v4().to_string(),
created_at: Utc::now().timestamp_millis() as u64,
initial_size: dimensions,
env,
events: Vec::new(),
duration: 0,
title: title
.clone()
.unwrap_or_else(|| "Terminal Recording".to_string()),
})
} else {
None
};
Ok(Self {
active: false,
format,
output_path,
writer: Some(writer),
recording,
start_time: std::time::Instant::now(),
dimensions,
title,
redact_passwords: true, password_prompt_active: false,
echo_suppressed: false,
redaction_marker_emitted: false,
})
}
pub fn start(&mut self) -> Result<()> {
if self.active {
return Ok(());
}
self.active = true;
self.start_time = std::time::Instant::now();
match self.format {
SessionLogFormat::Html => {
self.write_html_header()?;
}
SessionLogFormat::Plain => {
self.write_plain_redaction_warning()?;
}
SessionLogFormat::Asciicast => {
}
}
log::info!("Session logging started: {:?}", self.output_path);
Ok(())
}
pub fn stop(&mut self) -> Result<PathBuf> {
if !self.active {
return Ok(self.output_path.clone());
}
self.active = false;
super::format_writers::finalize_format(self)?;
if let Some(mut writer) = self.writer.take() {
writer
.flush()
.with_context(|| format!("Failed to flush session log: {:?}", self.output_path))?;
}
log::info!("Session logging stopped: {:?}", self.output_path);
Ok(self.output_path.clone())
}
pub fn set_redact_passwords(&mut self, enabled: bool) {
self.redact_passwords = enabled;
}
pub fn redact_passwords(&self) -> bool {
self.redact_passwords
}
pub fn set_echo_suppressed(&mut self, suppressed: bool) {
if self.echo_suppressed != suppressed {
self.echo_suppressed = suppressed;
if !suppressed {
self.redaction_marker_emitted = false;
}
}
}
pub fn echo_suppressed(&self) -> bool {
self.echo_suppressed
}
pub fn record_output(&mut self, data: &[u8]) {
if !self.active {
return;
}
if self.redact_passwords {
self.check_for_password_prompt(data);
}
let data_to_write: Vec<u8> = if self.redact_passwords {
self.filter_sensitive_output(data)
} else {
data.to_vec()
};
let data = data_to_write.as_slice();
let elapsed = self.start_time.elapsed().as_millis() as u64;
match self.format {
SessionLogFormat::Plain => {
let text = strip_ansi_escapes(data);
if let Some(ref mut writer) = self.writer {
let _ = writer.write_all(text.as_bytes());
}
}
SessionLogFormat::Html => {
let text = String::from_utf8_lossy(data);
let escaped = html_escape(&text);
if let Some(ref mut writer) = self.writer {
let _ = writer.write_all(escaped.as_bytes());
}
}
SessionLogFormat::Asciicast => {
if let Some(ref mut recording) = self.recording {
recording.events.push(RecordingEvent {
timestamp: elapsed,
event_type: RecordingEventType::Output,
data: data.to_vec(),
metadata: None,
});
recording.duration = elapsed;
}
}
}
}
pub fn record_input(&mut self, data: &[u8]) {
if !self.active {
return;
}
let is_suppressed = self.echo_suppressed || self.password_prompt_active;
if is_suppressed && self.redact_passwords {
let has_newline = data.iter().any(|&b| b == b'\n' || b == b'\r');
if !self.redaction_marker_emitted {
self.emit_redaction_marker();
self.redaction_marker_emitted = true;
}
if has_newline {
self.password_prompt_active = false;
self.redaction_marker_emitted = false;
}
return;
}
if self.format == SessionLogFormat::Asciicast {
let elapsed = self.start_time.elapsed().as_millis() as u64;
if let Some(ref mut recording) = self.recording {
recording.events.push(RecordingEvent {
timestamp: elapsed,
event_type: RecordingEventType::Input,
data: data.to_vec(),
metadata: None,
});
recording.duration = elapsed;
}
}
}
pub fn record_resize(&mut self, cols: usize, rows: usize) {
if !self.active {
return;
}
self.dimensions = (cols, rows);
if self.format == SessionLogFormat::Asciicast {
let elapsed = self.start_time.elapsed().as_millis() as u64;
if let Some(ref mut recording) = self.recording {
recording.events.push(RecordingEvent {
timestamp: elapsed,
event_type: RecordingEventType::Resize,
data: Vec::new(),
metadata: Some((cols, rows)),
});
recording.duration = elapsed;
}
}
}
pub fn is_active(&self) -> bool {
self.active
}
pub(super) fn line_is_sensitive(line: &str) -> bool {
let lower = line.to_ascii_lowercase();
SENSITIVE_OUTPUT_PATTERNS.iter().any(|p| lower.contains(p))
}
pub fn output_path(&self) -> &PathBuf {
&self.output_path
}
pub fn flush(&mut self) -> Result<()> {
if let Some(ref mut writer) = self.writer {
writer
.flush()
.with_context(|| format!("Failed to flush session log: {:?}", self.output_path))?;
}
Ok(())
}
fn filter_sensitive_output(&self, data: &[u8]) -> Vec<u8> {
const SENSITIVE_OUTPUT_MARKER: &str =
"[OUTPUT REDACTED - sensitive data heuristic matched]\n";
let stripped = strip_ansi_escapes(data);
let any_sensitive = stripped.lines().any(Self::line_is_sensitive);
if !any_sensitive {
return data.to_vec();
}
let mut result = Vec::with_capacity(data.len());
for line in stripped.lines() {
if Self::line_is_sensitive(line) {
result.extend_from_slice(SENSITIVE_OUTPUT_MARKER.as_bytes());
} else {
result.extend_from_slice(line.as_bytes());
result.push(b'\n');
}
}
if !stripped.ends_with('\n') && result.last() == Some(&b'\n') {
result.pop();
}
result
}
fn check_for_password_prompt(&mut self, data: &[u8]) {
let text = strip_ansi_escapes(data);
let last_line = text
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.unwrap_or("")
.to_ascii_lowercase();
if last_line.is_empty() {
return;
}
if PASSWORD_PROMPT_PATTERNS
.iter()
.any(|pattern| last_line.contains(pattern))
&& !self.password_prompt_active
{
self.password_prompt_active = true;
self.redaction_marker_emitted = false;
}
}
fn emit_redaction_marker(&mut self) {
if self.format == SessionLogFormat::Asciicast {
let elapsed = self.start_time.elapsed().as_millis() as u64;
if let Some(ref mut recording) = self.recording {
recording.events.push(RecordingEvent {
timestamp: elapsed,
event_type: RecordingEventType::Input,
data: REDACTION_MARKER.as_bytes().to_vec(),
metadata: None,
});
recording.duration = elapsed;
}
}
}
}
impl Drop for SessionLogger {
fn drop(&mut self) {
if self.active {
let _ = self.stop();
}
}
}
pub type SharedSessionLogger = Arc<Mutex<Option<SessionLogger>>>;
pub fn create_shared_logger() -> SharedSessionLogger {
Arc::new(Mutex::new(None))
}