use crate::config::Config;
use crate::database::{CommandEntry, Database, DatabaseStats};
use crate::error::{Error, Result};
use crate::redaction::RedactionEngine;
use chrono::{DateTime, Utc};
use regex::Regex;
use std::env;
use std::path::{Path, PathBuf};
use tracing::debug;
pub struct HistoryManagerDb {
config: Config,
pub(crate) db: Database,
redaction_engine: RedactionEngine,
}
#[derive(Debug, Clone)]
pub struct ExtractedToken {
pub token_type: String,
pub placeholder: String,
pub original_value: String,
}
impl HistoryManagerDb {
pub fn new(config: Config) -> Result<Self> {
let redaction_engine = RedactionEngine::with_config(
config.redaction.use_builtin_patterns,
config.redaction.custom_patterns.clone(),
config.redaction.exclude_patterns.clone(),
config.redaction.placeholder.clone(),
config.redaction.min_redaction_length,
config.custom_env_vars.clone(),
config.redaction.redact_env_vars,
)?;
let db_path = config.history_file.with_extension("db");
let db = Database::new(&db_path)?;
Ok(Self {
config,
db,
redaction_engine,
})
}
pub fn set_session_id(&mut self, session_id: &str) -> Result<()> {
self.db.resume_session(session_id)
}
pub fn log_command(&mut self, command: &str) -> Result<()> {
self.log_command_with_timestamp(command, None, None)
}
pub fn log_command_with_timestamp(
&mut self,
command: &str,
timestamp: Option<DateTime<Utc>>,
exit_code: Option<i32>,
) -> Result<()> {
if self.config.should_exclude_command(command) {
return Ok(());
}
let timestamp = timestamp.unwrap_or_else(Utc::now);
let directory = env::current_dir()
.unwrap_or_else(|_| PathBuf::from("<unknown>"))
.to_string_lossy()
.to_string();
let (redacted_command, tokens) =
if self.config.enable_redaction && !self.config.should_skip_redaction(command) {
let (redacted, extracted) = self.redact_and_extract_tokens(command)?;
let was_redacted = redacted != command;
if was_redacted {
debug!(
"Redacted sensitive data from command, extracted {} tokens",
extracted.len()
);
}
(redacted, if was_redacted { extracted } else { vec![] })
} else {
(command.to_string(), vec![])
};
let command_id = self.db.add_command(
&redacted_command,
&directory,
timestamp,
!tokens.is_empty(),
exit_code,
)?;
debug!("Logged command to database with ID {}", command_id);
for token in tokens {
self.db.store_token(
command_id,
&token.token_type,
&token.placeholder,
&token.original_value,
)?;
}
Ok(())
}
fn redact_and_extract_tokens(&self, command: &str) -> Result<(String, Vec<ExtractedToken>)> {
let mut tokens = Vec::new();
let mut redacted = command.to_string();
let patterns = vec![
(
r#"(?i)(?:password|passwd|pwd)[\s=:]+['"]?([^\s'"]{3,})['"]?"#,
"password",
),
(
r#"(?i)(?:token|api_key|apikey|api-key)[\s=:]+['"]?([^\s'"]{10,})['"]?"#,
"api_key",
),
(
r#"(?i)(?:secret|secret_key|secretkey)[\s=:]+['"]?([^\s'"]{10,})['"]?"#,
"secret",
),
(
r#"(?i)(?:bearer|authorization)[\s:]+['"]?([^\s'"]{10,})['"]?"#,
"bearer_token",
),
(r#"(?i)--password[=\s]+['"]?([^\s'"]{3,})['"]?"#, "password"),
(r#"(?i)-p\s+['"]?([^\s'"]{3,})['"]?"#, "password"),
];
for (pattern_str, token_type) in patterns {
let re = Regex::new(pattern_str)?;
for caps in re.captures_iter(&redacted.clone()) {
if let Some(matched) = caps.get(1) {
let original_value = matched.as_str().to_string();
if original_value.len() < self.config.redaction.min_redaction_length {
continue;
}
let placeholder = format!("<{}:{}>", token_type, tokens.len() + 1);
redacted = redacted.replace(&original_value, &placeholder);
tokens.push(ExtractedToken {
token_type: token_type.to_string(),
placeholder,
original_value,
});
}
}
}
if tokens.is_empty() {
redacted = self.redaction_engine.redact(&redacted)?;
}
Ok((redacted, tokens))
}
pub fn search(
&self,
query: &str,
directory_filter: Option<&str>,
host_filter: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<CommandEntry>> {
self.db
.search_commands(query, directory_filter, host_filter, limit)
}
pub fn get_recent(&self, limit: usize) -> Result<Vec<CommandEntry>> {
self.db.get_recent_commands(limit)
}
pub fn get_all_commands(&self) -> Result<Vec<CommandEntry>> {
self.db.get_all_commands()
}
pub fn get_stats(&self) -> Result<DatabaseStats> {
self.db.get_stats()
}
pub fn get_tokens_for_command(&self, command_id: i64) -> Result<Vec<crate::database::Token>> {
self.db
.get_tokens_for_command(crate::types::CommandId::new(command_id))
}
pub fn get_tokens_by_session(&self, session_id: &str) -> Result<Vec<crate::database::Token>> {
self.db.get_tokens_by_session(session_id)
}
pub fn get_tokens_by_directory(&self, directory: &str) -> Result<Vec<crate::database::Token>> {
self.db.get_tokens_by_directory(directory)
}
pub fn start_session(&mut self) -> Result<String> {
self.db.start_session()
}
pub fn end_session(&mut self, session_id: &str) -> Result<()> {
self.db.end_session(session_id)
}
pub fn import_from_bash(&mut self, path: Option<PathBuf>) -> Result<usize> {
let history_path = if let Some(p) = path {
p
} else {
let home = home::home_dir().ok_or(Error::HomeDirectoryNotFound)?;
home.join(".bash_history")
};
if !history_path.exists() {
return Err(Error::HistoryFileNotFound { path: history_path });
}
self.db.import_from_bash_history(&history_path)
}
pub fn import_from_zsh(&mut self, path: Option<PathBuf>) -> Result<usize> {
let history_path = if let Some(p) = path {
p
} else {
let home = home::home_dir().ok_or(Error::HomeDirectoryNotFound)?;
let zdotdir = env::var("ZDOTDIR").ok().map(PathBuf::from);
let base_dir = zdotdir.unwrap_or(home);
base_dir.join(".zsh_history")
};
if !history_path.exists() {
return Err(Error::HistoryFileNotFound { path: history_path });
}
self.db.import_from_zsh_history(&history_path)
}
pub fn import_from_fish(&mut self, path: Option<PathBuf>) -> Result<usize> {
let history_path = if let Some(p) = path {
p
} else {
let home = home::home_dir().ok_or(Error::HomeDirectoryNotFound)?;
let config_dir = env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| home.join(".config"));
config_dir.join("fish").join("fish_history")
};
if !history_path.exists() {
return Err(Error::HistoryFileNotFound { path: history_path });
}
let content = std::fs::read_to_string(&history_path)?;
let mut imported_count = 0;
let mut current_cmd: Option<String> = None;
let mut current_time: Option<DateTime<Utc>> = None;
for line in content.lines() {
let line = line.trim();
if line.starts_with("- cmd: ") {
if let (Some(cmd), Some(time)) = (current_cmd.take(), current_time.take()) {
self.db.add_command(&cmd, "<imported>", time, false, None)?;
imported_count += 1;
}
current_cmd = Some(line.trim_start_matches("- cmd: ").to_string());
} else if line.starts_with("when: ")
&& let Ok(timestamp) = line.trim_start_matches("when: ").parse::<i64>()
&& let Some(dt) = DateTime::from_timestamp(timestamp, 0)
{
current_time = Some(dt);
}
}
if let (Some(cmd), Some(time)) = (current_cmd, current_time) {
self.db.add_command(&cmd, "<imported>", time, false, None)?;
imported_count += 1;
}
Ok(imported_count)
}
pub fn merge_from_database(&mut self, other_db_path: &Path) -> Result<usize> {
if !other_db_path.exists() {
return Err(Error::HistoryFileNotFound {
path: other_db_path.to_path_buf(),
});
}
self.db.merge_from_database(other_db_path)
}
pub fn get_hosts(&self) -> Result<Vec<crate::database::Host>> {
self.db.get_hosts()
}
pub fn get_sessions_for_host(&self, host_id: i64) -> Result<Vec<crate::database::Session>> {
self.db
.get_sessions_for_host(crate::types::HostId::new(host_id))
}
pub fn get_commands_for_session(
&self,
session_id: &str,
) -> Result<Vec<crate::database::CommandEntry>> {
self.db.get_commands_for_session(session_id)
}
pub fn clear(&self) -> Result<()> {
self.db.clear()
}
}
impl crate::backend::HistoryProvider for HistoryManagerDb {
fn get_entries(&self) -> Result<Vec<crate::history::HistoryEntry>> {
Ok(self
.get_all_commands()?
.into_iter()
.map(Into::into)
.collect())
}
fn get_recent(&self, count: usize) -> Result<Vec<crate::history::HistoryEntry>> {
Ok(self
.get_recent(count)?
.into_iter()
.map(Into::into)
.collect())
}
fn search(&self, query: &str) -> Result<Vec<crate::history::HistoryEntry>> {
Ok(self
.search(query, None, None, None)?
.into_iter()
.map(Into::into)
.collect())
}
fn log_command(&mut self, command: &str) -> Result<()> {
HistoryManagerDb::log_command(self, command)
}
fn clear(&mut self) -> Result<()> {
self.db.clear()
}
fn delete_entries(&mut self, indices: &[usize]) -> Result<usize> {
if indices.is_empty() {
return Ok(0);
}
let entries = self.get_all_commands()?;
let mut ids_to_delete = Vec::new();
for &idx in indices {
if let Some(entry) = entries.get(idx) {
ids_to_delete.push(entry.id);
}
}
let mut deleted = 0;
for id in ids_to_delete {
self.db.delete_command(id)?;
deleted += 1;
}
Ok(deleted)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_config() -> (Config, TempDir) {
let temp_dir = TempDir::new().unwrap();
let temp_file = temp_dir.path().join("test.log");
let mut config = Config {
history_file: temp_file,
enable_redaction: true,
..Default::default()
};
config.shell_integration.exclude_commands.clear();
(config, temp_dir)
}
#[test]
fn test_history_manager_creation() {
let (config, _temp_dir) = test_config();
let manager = HistoryManagerDb::new(config);
assert!(manager.is_ok());
}
#[test]
fn test_log_command() {
let (config, _temp_dir) = test_config();
let mut manager = HistoryManagerDb::new(config).unwrap();
let result = manager.log_command("ls -la");
assert!(result.is_ok());
let stats = manager.get_stats().unwrap();
assert_eq!(stats.total_commands, 1);
}
#[test]
fn test_redaction_with_tokens() {
let (config, _temp_dir) = test_config();
let mut manager = HistoryManagerDb::new(config).unwrap();
manager
.log_command("mysql -u root -p secret123 -h localhost")
.unwrap();
let commands = manager.get_recent(10).unwrap();
assert_eq!(commands.len(), 1);
assert!(commands[0].redacted);
assert!(!commands[0].command.contains("secret123"));
}
#[test]
fn test_token_extraction() {
let (config, _temp_dir) = test_config();
let manager = HistoryManagerDb::new(config).unwrap();
let (redacted, tokens) = manager
.redact_and_extract_tokens("export API_KEY=abc123xyz456")
.unwrap();
assert!(!tokens.is_empty());
assert!(!redacted.contains("abc123xyz456"));
}
#[test]
fn test_search() {
let (config, _temp_dir) = test_config();
let mut manager = HistoryManagerDb::new(config).unwrap();
manager.log_command("git status").unwrap();
manager.log_command("git commit -m 'test'").unwrap();
manager.log_command("ls -la").unwrap();
let results = manager.search("git", None, None, None).unwrap();
assert_eq!(results.len(), 2);
}
#[test]
fn test_token_retrieval() {
let (config, _temp_dir) = test_config();
let mut manager = HistoryManagerDb::new(config).unwrap();
manager.log_command("export PASSWORD=mypass123").unwrap();
let commands = manager.get_recent(1).unwrap();
let tokens = manager
.get_tokens_for_command(commands[0].id.as_i64())
.unwrap();
assert!(!tokens.is_empty());
}
}