use std::{
collections::HashMap,
env,
fmt::Write as FmtWrite,
fs::File,
io,
io::{BufReader, BufWriter, Read, Write as IoWrite},
os::unix::{
ffi::{OsStrExt, OsStringExt},
fs::MetadataExt,
},
path::{Path, PathBuf},
str,
sync::Arc,
time::Duration,
};
use bstr::{BString, ByteSlice, io::BufReadExt};
use chrono::prelude::{Local, TimeZone};
use itertools::Itertools;
use regex::bytes::Regex;
use rusqlite::{Connection, Error, Result, Row, Transaction, functions::FunctionFlags};
use serde::{Deserialize, Serialize};
type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
pub mod recall;
pub mod secrets_patterns;
pub fn get_setting(
conn: &Connection,
key: &str,
) -> Result<Option<BString>, Box<dyn std::error::Error>> {
let mut stmt = conn.prepare("SELECT value FROM settings WHERE key = ?")?;
let mut rows = stmt.query([key])?;
if let Some(row) = rows.next()? {
let value: Vec<u8> = row.get(0)?;
Ok(Some(BString::from(value)))
} else {
Ok(None)
}
}
pub fn set_setting(
conn: &Connection,
key: &str,
value: &BString,
) -> Result<(), Box<dyn std::error::Error>> {
conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
(key, value.as_bytes()),
)?;
Ok(())
}
const TIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
pub fn get_hostname() -> BString {
let hostname =
env::var_os("PXH_HOSTNAME").unwrap_or_else(|| hostname::get().unwrap_or_default());
let hostname_bytes = hostname.as_bytes();
if let Some(dot_pos) = hostname_bytes.iter().position(|&b| b == b'.') {
BString::from(&hostname_bytes[..dot_pos])
} else {
BString::from(hostname_bytes)
}
}
fn resolve_through_symlinks(path: &Path) -> PathBuf {
let mut resolved = PathBuf::new();
for component in path.components() {
resolved.push(component);
if let Ok(canonical) = std::fs::canonicalize(&resolved) {
resolved = canonical;
} else if let Ok(target) = std::fs::read_link(&resolved) {
if target.is_absolute() {
resolved = target;
} else {
resolved.pop();
resolved.push(target);
}
}
}
resolved
}
pub fn resolve_hostname(config: &recall::config::Config, conn: &Connection) -> BString {
if let Some(ref h) = config.host.hostname {
return BString::from(h.as_bytes());
}
get_setting(conn, "original_hostname").ok().flatten().unwrap_or_else(get_hostname)
}
pub fn effective_host_set(config: &recall::config::Config) -> Vec<BString> {
let current = get_hostname();
let mut hosts = vec![current];
for alias in &config.host.aliases {
let b = BString::from(alias.as_bytes());
if !hosts.contains(&b) {
hosts.push(b);
}
}
hosts
}
fn migrate_host_settings(conn: &Connection) {
let config = recall::config::Config::load();
let mut updates: Vec<(&str, toml_edit::Item)> = Vec::new();
let live_hostname = get_hostname();
let config_hostname = if let Some(ref h) = config.host.hostname {
BString::from(h.as_bytes())
} else if let Ok(Some(hostname)) = get_setting(conn, "original_hostname") {
updates.push(("host.hostname", toml_edit::value(hostname.to_string())));
hostname
} else {
updates.push(("host.hostname", toml_edit::value(live_hostname.to_string())));
live_hostname.clone()
};
if config_hostname != live_hostname {
let mut aliases = config.host.aliases.clone();
let old_str = config_hostname.to_string();
if !aliases.contains(&old_str) {
aliases.push(old_str);
}
let alias_array = toml_edit::Array::from_iter(aliases.iter().map(|s| s.as_str()));
updates.push(("host.aliases", toml_edit::value(alias_array)));
updates.push(("host.hostname", toml_edit::value(live_hostname.to_string())));
}
if config.host.machine_id.is_none() {
let id = rand::random::<u64>();
updates.push(("host.machine_id", toml_edit::value(id as i64)));
}
if !updates.is_empty()
&& let Err(e) = recall::config::Config::update_default_config(&updates)
{
log::warn!("Failed to migrate host settings to config: {e}");
return;
}
if config.host.hostname.is_none() {
let _ = conn.execute("DELETE FROM settings WHERE key = 'original_hostname'", []);
}
}
pub fn sqlite_connection(path: &Option<PathBuf>) -> Result<Connection, Box<dyn std::error::Error>> {
let path = path.as_ref().ok_or("Database not defined; use --db or PXH_DB_PATH")?;
if let Some(parent) = path.parent() {
let resolved = resolve_through_symlinks(parent);
std::fs::create_dir_all(resolved)?;
}
let conn = Connection::open(path)?;
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = std::fs::metadata(path) {
let mode = metadata.permissions().mode();
if mode & 0o077 != 0 {
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
}
conn.busy_timeout(Duration::from_millis(5000))?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "temp_store", "MEMORY")?;
conn.pragma_update(None, "cache_size", "16777216")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
let schema = include_str!("base_schema.sql");
conn.execute_batch(schema)?;
conn.create_scalar_function("regexp", 2, FunctionFlags::SQLITE_DETERMINISTIC, move |ctx| {
assert_eq!(ctx.len(), 2, "called with unexpected number of arguments");
let regexp: Arc<Regex> = ctx
.get_or_create_aux(0, |vr| -> Result<_, BoxError> { Ok(Regex::new(vr.as_str()?)?) })?;
let is_match = {
let text = ctx.get_raw(1).as_bytes().map_err(|e| Error::UserFunctionError(e.into()))?;
regexp.is_match(text)
};
Ok(is_match)
})?;
let _ = conn.execute("ALTER TABLE command_history ADD COLUMN machine_id INTEGER", []);
migrate_host_settings(&conn);
Ok(conn)
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Invocation {
pub command: BString,
pub shellname: String,
pub working_directory: Option<BString>,
pub hostname: Option<BString>,
pub username: Option<BString>,
pub exit_status: Option<i64>,
pub start_unix_timestamp: Option<i64>,
pub end_unix_timestamp: Option<i64>,
pub session_id: i64,
#[serde(default)]
pub machine_id: Option<u64>,
}
impl Invocation {
fn sameish(&self, other: &Self) -> bool {
self.command == other.command && self.start_unix_timestamp == other.start_unix_timestamp
}
pub fn insert(&self, tx: &Transaction) -> Result<(), Box<dyn std::error::Error>> {
tx.execute(
r#"
INSERT OR IGNORE INTO command_history (
session_id,
full_command,
shellname,
hostname,
username,
working_directory,
exit_status,
start_unix_timestamp,
end_unix_timestamp,
machine_id
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
(
self.session_id,
self.command.as_slice(),
self.shellname.clone(),
self.hostname.as_ref().map(|v| v.to_vec()),
self.username.as_ref().map(|v| v.to_vec()),
self.working_directory.as_ref().map(|v| v.to_vec()),
self.exit_status,
self.start_unix_timestamp,
self.end_unix_timestamp,
self.machine_id.map(|id| id as i64),
),
)?;
Ok(())
}
}
fn generate_import_session_id(histfile: &Path) -> i64 {
if let Ok(st) = std::fs::metadata(histfile) {
((st.ino() << 16) | st.dev()) as i64
} else {
(rand::random::<u64>() >> 1) as i64
}
}
pub fn import_zsh_history(
histfile: &Path,
hostname: Option<BString>,
username: Option<BString>,
) -> Result<Vec<Invocation>, Box<dyn std::error::Error>> {
let mut f = File::open(histfile)?;
let mut buf = Vec::new();
let _ = f.read_to_end(&mut buf)?;
let username = username
.or_else(|| uzers::get_current_username().map(|v| BString::from(v.into_vec())))
.unwrap_or_else(|| BString::from("unknown"));
let hostname = hostname.unwrap_or_else(get_hostname);
let buf_iter = buf.split(|&ch| ch == b'\n');
let mut ret = vec![];
let mut skipped = 0usize;
let session_id = generate_import_session_id(histfile);
for (line_num, line) in buf_iter.enumerate() {
let Some((fields, command)) = line.splitn(2, |&ch| ch == b';').collect_tuple() else {
continue;
};
let Some((_skip, start_time, duration_seconds)) =
fields.splitn(3, |&ch| ch == b':').collect_tuple()
else {
continue;
};
let start_unix_timestamp =
match str::from_utf8(&start_time[1..]).ok().and_then(|s| s.parse::<i64>().ok()) {
Some(ts) => ts,
None => {
eprintln!(
"warning: {}: skipping line {}: bad timestamp {:?}",
histfile.display(),
line_num + 1,
BString::from(start_time),
);
skipped += 1;
continue;
}
};
let duration =
match str::from_utf8(duration_seconds).ok().and_then(|s| s.parse::<i64>().ok()) {
Some(d) => d,
None => {
eprintln!(
"warning: {}: skipping line {}: bad duration {:?}",
histfile.display(),
line_num + 1,
BString::from(duration_seconds),
);
skipped += 1;
continue;
}
};
let invocation = Invocation {
command: BString::from(command),
shellname: "zsh".into(),
hostname: Some(BString::from(hostname.as_bytes())),
username: Some(BString::from(username.as_bytes())),
start_unix_timestamp: Some(start_unix_timestamp),
end_unix_timestamp: Some(start_unix_timestamp + duration),
session_id,
..Default::default()
};
ret.push(invocation);
}
if skipped > 0 {
eprintln!("warning: {}: skipped {skipped} malformed line(s)", histfile.display());
}
Ok(dedup_invocations(ret))
}
pub fn import_bash_history(
histfile: &Path,
hostname: Option<BString>,
username: Option<BString>,
) -> Result<Vec<Invocation>, Box<dyn std::error::Error>> {
let mut f = File::open(histfile)?;
let mut buf = Vec::new();
let _ = f.read_to_end(&mut buf)?;
let username = username
.or_else(|| uzers::get_current_username().map(|v| BString::from(v.as_bytes())))
.unwrap_or_else(|| BString::from("unknown"));
let hostname = hostname.unwrap_or_else(get_hostname);
let buf_iter = buf.split(|&ch| ch == b'\n').filter(|l| !l.is_empty());
let mut ret = vec![];
let session_id = generate_import_session_id(histfile);
let mut last_ts = None;
for line in buf_iter {
if line[0] == b'#'
&& let Ok(ts) = str::parse::<i64>(str::from_utf8(&line[1..]).unwrap_or("0"))
{
if ts > 0 {
last_ts = Some(ts);
}
continue;
}
let invocation = Invocation {
command: BString::from(line),
shellname: "bash".into(),
hostname: Some(BString::from(hostname.as_bytes())),
username: Some(BString::from(username.as_bytes())),
start_unix_timestamp: last_ts,
session_id,
..Default::default()
};
ret.push(invocation);
}
Ok(dedup_invocations(ret))
}
pub fn import_json_history(histfile: &Path) -> Result<Vec<Invocation>, Box<dyn std::error::Error>> {
let f = File::open(histfile)?;
let reader = BufReader::new(f);
Ok(serde_json::from_reader(reader)?)
}
fn dedup_invocations(invocations: Vec<Invocation>) -> Vec<Invocation> {
let mut it = invocations.into_iter();
let Some(first) = it.next() else { return vec![] };
let mut ret = vec![first];
for elem in it {
if !elem.sameish(ret.last().unwrap()) {
ret.push(elem);
}
}
ret
}
impl Invocation {
pub fn from_row(row: &Row) -> Result<Self, Error> {
Ok(Invocation {
session_id: row.get("session_id")?,
command: BString::from(row.get::<_, Vec<u8>>("full_command")?),
shellname: row.get("shellname")?,
working_directory: row
.get::<_, Option<Vec<u8>>>("working_directory")?
.map(BString::from),
hostname: row.get::<_, Option<Vec<u8>>>("hostname")?.map(BString::from),
username: row.get::<_, Option<Vec<u8>>>("username")?.map(BString::from),
exit_status: row.get("exit_status")?,
start_unix_timestamp: row.get("start_unix_timestamp")?,
end_unix_timestamp: row.get("end_unix_timestamp")?,
machine_id: row.get::<_, Option<i64>>("machine_id").ok().flatten().map(|v| v as u64),
})
}
}
#[derive(Debug, Eq, PartialEq, Serialize, Deserialize, Clone)]
#[serde(untagged)]
enum PrettyExportString {
Readable(String),
Encoded(Vec<u8>),
}
impl From<&[u8]> for PrettyExportString {
fn from(bytes: &[u8]) -> Self {
match str::from_utf8(bytes) {
Ok(v) => Self::Readable(v.to_string()),
_ => Self::Encoded(bytes.to_vec()),
}
}
}
impl From<Option<&Vec<u8>>> for PrettyExportString {
fn from(bytes: Option<&Vec<u8>>) -> Self {
match bytes {
Some(v) => match str::from_utf8(v.as_slice()) {
Ok(s) => Self::Readable(s.to_string()),
_ => Self::Encoded(v.to_vec()),
},
None => Self::Readable(String::new()),
}
}
}
impl Invocation {
fn to_json_export(&self) -> serde_json::Value {
serde_json::json!({
"session_id": self.session_id,
"command": PrettyExportString::from(self.command.as_slice()),
"shellname": self.shellname,
"working_directory": self.working_directory.as_ref().map_or(
PrettyExportString::Readable(String::new()),
|b| PrettyExportString::from(b.as_slice())
),
"hostname": self.hostname.as_ref().map_or(
PrettyExportString::Readable(String::new()),
|b| PrettyExportString::from(b.as_slice())
),
"username": self.username.as_ref().map_or(
PrettyExportString::Readable(String::new()),
|b| PrettyExportString::from(b.as_slice())
),
"exit_status": self.exit_status,
"start_unix_timestamp": self.start_unix_timestamp,
"end_unix_timestamp": self.end_unix_timestamp,
})
}
}
pub fn json_export(rows: &[Invocation]) -> Result<(), Box<dyn std::error::Error>> {
let json_values: Vec<serde_json::Value> = rows.iter().map(|r| r.to_json_export()).collect();
serde_json::to_writer(io::stdout(), &json_values)?;
Ok(())
}
struct QueryResultColumnDisplayer {
header: &'static str,
style: &'static str,
displayer: Box<dyn Fn(&Invocation) -> String>,
}
fn time_display_helper(t: Option<i64>) -> String {
t.and_then(|t| Local.timestamp_opt(t, 0).single())
.map(|t| t.format(TIME_FORMAT).to_string())
.unwrap_or_else(|| "n/a".to_string())
}
fn binary_display_helper(v: &BString) -> String {
String::from_utf8_lossy(v.as_slice()).to_string()
}
fn displayers() -> HashMap<&'static str, QueryResultColumnDisplayer> {
let mut ret = HashMap::new();
ret.insert(
"command",
QueryResultColumnDisplayer {
header: "Command",
style: "Fw",
displayer: Box::new(|row| binary_display_helper(&row.command)),
},
);
ret.insert(
"start_time",
QueryResultColumnDisplayer {
header: "Start",
style: "Fg",
displayer: Box::new(|row| time_display_helper(row.start_unix_timestamp)),
},
);
ret.insert(
"end_time",
QueryResultColumnDisplayer {
header: "End",
style: "Fg",
displayer: Box::new(|row| time_display_helper(row.end_unix_timestamp)),
},
);
ret.insert(
"duration",
QueryResultColumnDisplayer {
header: "Duration",
style: "Fm",
displayer: Box::new(|row| match (row.start_unix_timestamp, row.end_unix_timestamp) {
(Some(start), Some(end)) => format!("{}s", end - start),
_ => "n/a".into(),
}),
},
);
ret.insert(
"status",
QueryResultColumnDisplayer {
header: "Status",
style: "Fr",
displayer: Box::new(|row| {
row.exit_status.map_or_else(|| "n/a".into(), |s| s.to_string())
}),
},
);
ret.insert(
"session",
QueryResultColumnDisplayer {
header: "Session",
style: "Fc",
displayer: Box::new(|row| format!("{:x}", row.session_id)),
},
);
ret.insert(
"context",
QueryResultColumnDisplayer {
header: "Context",
style: "bFb",
displayer: Box::new(|row| {
let current_hostname = get_hostname();
let row_hostname = row.hostname.clone().unwrap_or_default();
let mut ret = String::new();
if current_hostname != row_hostname {
write!(ret, "{row_hostname}:").unwrap_or_default();
}
let current_directory = env::current_dir().unwrap_or_default();
ret.push_str(&row.working_directory.as_ref().map_or_else(String::new, |v| {
let v = String::from_utf8_lossy(v.as_slice()).to_string();
if v == current_directory.to_string_lossy() { String::from(".") } else { v }
}));
ret
}),
},
);
ret
}
pub fn present_results_human_readable(
fields: &[&str],
rows: &[Invocation],
suppress_headers: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let displayers = displayers();
let mut table = prettytable::Table::new();
table.set_format(*prettytable::format::consts::FORMAT_CLEAN);
if !suppress_headers {
let mut title_row = prettytable::Row::empty();
for field in fields {
let Some(d) = displayers.get(field) else {
return Err(Box::from(format!("Invalid 'show' field: {field}")));
};
title_row.add_cell(prettytable::Cell::new(d.header).style_spec("bFg"));
}
table.set_titles(title_row);
}
for row in rows.iter() {
let mut display_row = prettytable::Row::empty();
for field in fields {
display_row.add_cell(
prettytable::Cell::new((displayers[field].displayer)(row).as_str())
.style_spec(displayers[field].style),
);
}
table.add_row(display_row);
}
table.printstd();
Ok(())
}
pub fn atomically_remove_lines_from_file(
input_filepath: &PathBuf,
contraband: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let input_file = File::open(input_filepath)?;
let mut input_reader = BufReader::new(input_file);
let parent = input_filepath.parent().unwrap_or(Path::new("."));
let temp_file = tempfile::NamedTempFile::new_in(parent)?;
let mut output_writer = BufWriter::new(&temp_file);
input_reader.for_byte_line_with_terminator(|line| {
if !line.contains_str(contraband) {
output_writer.write_all(line)?;
}
Ok(true)
})?;
output_writer.flush()?;
drop(output_writer);
temp_file.persist(input_filepath)?;
Ok(())
}
pub fn atomically_remove_matching_lines_from_file(
input_filepath: &Path,
contraband_items: &[&str],
) -> Result<(), Box<dyn std::error::Error>> {
use std::collections::HashSet;
let contraband_set: HashSet<&str> = contraband_items.iter().copied().collect();
let input_file = File::open(input_filepath)?;
let mut input_reader = BufReader::new(input_file);
let parent = input_filepath.parent().unwrap_or(Path::new("."));
let temp_file = tempfile::NamedTempFile::new_in(parent)?;
let mut output_writer = BufWriter::new(&temp_file);
input_reader.for_byte_line_with_terminator(|line| {
let line_str = line.to_str_lossy();
let trimmed = line_str.trim();
if !contraband_set.contains(trimmed) {
output_writer.write_all(line)?;
}
Ok(true)
})?;
output_writer.flush()?;
drop(output_writer);
temp_file.persist(input_filepath)?;
Ok(())
}
pub mod helpers {
use std::path::{Path, PathBuf};
pub fn parse_ssh_command(ssh_cmd: &str) -> (String, Vec<String>) {
if !ssh_cmd.contains(char::is_whitespace) {
return (ssh_cmd.to_string(), vec![]);
}
let mut cmd = String::new();
let mut args = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
let mut quote_char = '\0';
let mut is_first = true;
let mut chars = ssh_cmd.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'"' | '\'' if !in_quotes => {
in_quotes = true;
quote_char = ch;
}
'"' | '\'' if in_quotes && ch == quote_char => {
in_quotes = false;
quote_char = '\0';
}
' ' | '\t' if !in_quotes => {
if !current.is_empty() {
if is_first {
cmd = current.clone();
is_first = false;
} else {
args.push(current.clone());
}
current.clear();
}
}
'\\' if chars.peek().is_some() => {
if let Some(next_ch) = chars.next() {
current.push(next_ch);
}
}
_ => {
current.push(ch);
}
}
}
if !current.is_empty() {
if is_first {
cmd = current;
} else {
args.push(current);
}
}
(cmd, args)
}
fn remote_pxh_candidates(configured_path: &str) -> Vec<String> {
let mut candidates = Vec::new();
if configured_path != "pxh" {
candidates.push(configured_path.to_string());
return candidates;
}
if let Some(rel) = get_relative_path_from_home(None, None)
&& rel != "pxh"
{
candidates.push(format!("$HOME/{rel}"));
}
for p in [
"$HOME/.cargo/bin/pxh",
"$HOME/bin/pxh",
"$HOME/.local/bin/pxh",
"/usr/local/bin/pxh",
"/usr/bin/pxh",
] {
if !candidates.contains(&p.to_string()) {
candidates.push(p.to_string());
}
}
candidates
}
pub fn build_remote_pxh_command(configured_path: &str, args: &str) -> String {
let candidates = remote_pxh_candidates(configured_path);
if candidates.len() == 1 {
return format!("{} {args}", candidates[0]);
}
let checks: Vec<String> =
candidates.iter().map(|p| format!("[ -x \"{p}\" ] && exec \"{p}\" {args}")).collect();
format!(
"sh -c '{}; echo \"pxh: not found on remote host\" >&2; exit 127'",
checks.join("; ")
)
}
pub fn get_relative_path_from_home(
exe_override: Option<&Path>,
home_override: Option<&Path>,
) -> Option<String> {
let exe = match exe_override {
Some(path) => path.to_path_buf(),
None => std::env::current_exe().ok()?,
};
let home = match home_override {
Some(path) => path.to_path_buf(),
None => home::home_dir()?,
};
exe.strip_prefix(&home).ok().map(|path| path.to_string_lossy().to_string())
}
pub fn determine_is_pxhs(args: &[String]) -> bool {
args.first()
.and_then(|arg| {
PathBuf::from(arg).file_name().map(|name| name.to_string_lossy().contains("pxhs"))
})
.unwrap_or(false)
}
}
#[doc(hidden)]
pub mod test_utils {
use std::{
env,
path::{Path, PathBuf},
process::Command,
};
use rand::{RngExt, distr::Alphanumeric};
use tempfile::TempDir;
pub fn pxh_path() -> PathBuf {
let mut path = std::env::current_exe().unwrap();
path.pop(); path.pop(); path.push("pxh");
assert!(path.exists(), "pxh binary not found at {:?}", path);
path
}
fn generate_random_string(length: usize) -> String {
rand::rng().sample_iter(&Alphanumeric).take(length).map(char::from).collect()
}
fn get_standard_path() -> String {
Command::new("getconf")
.arg("PATH")
.output()
.ok()
.and_then(|output| {
if output.status.success() { String::from_utf8(output.stdout).ok() } else { None }
})
.map(|s| s.trim().to_string())
.unwrap_or_else(|| {
"/usr/bin:/bin:/usr/sbin:/sbin".to_string()
})
}
pub struct PxhTestHelper {
_tmpdir: TempDir,
pub hostname: String,
pub username: String,
home_dir: PathBuf,
db_path: PathBuf,
}
impl PxhTestHelper {
pub fn new() -> Self {
let tmpdir = TempDir::new().unwrap();
let home_dir = tmpdir.path().to_path_buf();
let db_path = home_dir.join(".pxh/pxh.db");
PxhTestHelper {
_tmpdir: tmpdir,
hostname: generate_random_string(12),
username: "testuser".to_string(),
home_dir,
db_path,
}
}
pub fn with_custom_db_path(mut self, db_path: impl AsRef<Path>) -> Self {
self.db_path = self.home_dir.join(db_path);
self
}
pub fn home_dir(&self) -> &Path {
&self.home_dir
}
pub fn db_path(&self) -> &Path {
&self.db_path
}
pub fn get_full_path(&self) -> String {
format!("{}:{}", pxh_path().parent().unwrap().display(), get_standard_path())
}
pub fn command(&self) -> Command {
let mut cmd = Command::new(pxh_path());
cmd.env_clear();
cmd.env("HOME", &self.home_dir);
cmd.env("PXH_DB_PATH", &self.db_path);
cmd.env("PXH_HOSTNAME", &self.hostname);
cmd.env("USER", &self.username);
cmd.env("PATH", self.get_full_path());
if let Ok(profile_file) = env::var("LLVM_PROFILE_FILE") {
cmd.env("LLVM_PROFILE_FILE", profile_file);
}
if let Ok(llvm_cov) = env::var("CARGO_LLVM_COV") {
cmd.env("CARGO_LLVM_COV", llvm_cov);
}
cmd
}
pub fn command_with_args(&self, args: &[&str]) -> Command {
let mut cmd = self.command();
cmd.args(args);
cmd
}
pub fn shell_command(&self, shell: &str) -> Command {
let mut cmd = Command::new(shell);
cmd.arg("-i");
cmd.env_clear();
cmd.env("HOME", &self.home_dir);
cmd.env("PXH_DB_PATH", &self.db_path);
cmd.env("PXH_HOSTNAME", &self.hostname);
cmd.env("PATH", self.get_full_path());
cmd.env("USER", &self.username);
cmd.env("SHELL", shell);
cmd.env("BASH_ENV", self.home_dir.join(".bashrc"));
cmd
}
}
impl Default for PxhTestHelper {
fn default() -> Self {
Self::new()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_hostname_from_config() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(include_str!("base_schema.sql")).unwrap();
let _ = conn.execute("ALTER TABLE command_history ADD COLUMN machine_id INTEGER", []);
let mut config = recall::config::Config::default();
config.host.hostname = Some("from-config".to_string());
set_setting(&conn, "original_hostname", &BString::from("from-db")).unwrap();
let result = resolve_hostname(&config, &conn);
assert_eq!(result, BString::from("from-config"));
}
#[test]
fn test_resolve_hostname_from_db() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(include_str!("base_schema.sql")).unwrap();
let _ = conn.execute("ALTER TABLE command_history ADD COLUMN machine_id INTEGER", []);
let config = recall::config::Config::default();
set_setting(&conn, "original_hostname", &BString::from("from-db")).unwrap();
let result = resolve_hostname(&config, &conn);
assert_eq!(result, BString::from("from-db"));
}
#[test]
fn test_resolve_hostname_live_fallback() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(include_str!("base_schema.sql")).unwrap();
let _ = conn.execute("ALTER TABLE command_history ADD COLUMN machine_id INTEGER", []);
let config = recall::config::Config::default();
let result = resolve_hostname(&config, &conn);
assert_eq!(result, get_hostname());
}
#[test]
fn test_effective_host_set_no_aliases() {
let config = recall::config::Config::default();
let hosts = effective_host_set(&config);
assert_eq!(hosts, vec![get_hostname()]);
}
#[test]
fn test_effective_host_set_with_aliases() {
let mut config = recall::config::Config::default();
config.host.aliases = vec!["old-host".to_string(), "other-host".to_string()];
let hosts = effective_host_set(&config);
assert_eq!(hosts.len(), 3);
assert_eq!(hosts[0], get_hostname());
assert_eq!(hosts[1], BString::from("old-host"));
assert_eq!(hosts[2], BString::from("other-host"));
}
#[test]
fn test_effective_host_set_dedup() {
let mut config = recall::config::Config::default();
let current = get_hostname().to_string();
config.host.aliases = vec![current, "other".to_string()];
let hosts = effective_host_set(&config);
assert_eq!(hosts.len(), 2);
}
}