1use std::{
2 collections::HashMap,
3 env,
4 fmt::Write as FmtWrite,
5 fs::File,
6 io,
7 io::{BufReader, BufWriter, Read, Write as IoWrite},
8 os::unix::{
9 ffi::{OsStrExt, OsStringExt},
10 fs::MetadataExt,
11 },
12 path::{Path, PathBuf},
13 str,
14 sync::Arc,
15 time::Duration,
16};
17
18use bstr::{BString, ByteSlice, io::BufReadExt};
19use chrono::prelude::{Local, TimeZone};
20use itertools::Itertools;
21use regex::bytes::Regex;
22use rusqlite::{Connection, Error, Result, Row, Transaction, functions::FunctionFlags};
23use serde::{Deserialize, Serialize};
24
25type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
26
27pub mod recall;
28pub mod secrets_patterns;
29
30pub fn get_setting(
31 conn: &Connection,
32 key: &str,
33) -> Result<Option<BString>, Box<dyn std::error::Error>> {
34 let mut stmt = conn.prepare("SELECT value FROM settings WHERE key = ?")?;
35 let mut rows = stmt.query([key])?;
36
37 if let Some(row) = rows.next()? {
38 let value: Vec<u8> = row.get(0)?;
39 Ok(Some(BString::from(value)))
40 } else {
41 Ok(None)
42 }
43}
44
45pub fn set_setting(
46 conn: &Connection,
47 key: &str,
48 value: &BString,
49) -> Result<(), Box<dyn std::error::Error>> {
50 conn.execute(
51 "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
52 (key, value.as_bytes()),
53 )?;
54 Ok(())
55}
56
57const TIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
58
59pub fn get_hostname() -> BString {
60 let hostname =
61 env::var_os("PXH_HOSTNAME").unwrap_or_else(|| hostname::get().unwrap_or_default());
62
63 let hostname_bytes = hostname.as_bytes();
67 if let Some(dot_pos) = hostname_bytes.iter().position(|&b| b == b'.') {
68 BString::from(&hostname_bytes[..dot_pos])
69 } else {
70 BString::from(hostname_bytes)
71 }
72}
73
74fn resolve_through_symlinks(path: &Path) -> PathBuf {
78 let mut resolved = PathBuf::new();
79 for component in path.components() {
80 resolved.push(component);
81 if let Ok(canonical) = std::fs::canonicalize(&resolved) {
82 resolved = canonical;
83 } else if let Ok(target) = std::fs::read_link(&resolved) {
84 if target.is_absolute() {
86 resolved = target;
87 } else {
88 resolved.pop();
89 resolved.push(target);
90 }
91 }
92 }
93 resolved
94}
95
96pub fn resolve_hostname(config: &recall::config::Config, conn: &Connection) -> BString {
98 if let Some(ref h) = config.host.hostname {
99 return BString::from(h.as_bytes());
100 }
101 get_setting(conn, "original_hostname").ok().flatten().unwrap_or_else(get_hostname)
102}
103
104pub fn effective_host_set(config: &recall::config::Config) -> Vec<BString> {
107 let current = get_hostname();
108 let mut hosts = vec![current];
109 for alias in &config.host.aliases {
110 let b = BString::from(alias.as_bytes());
111 if !hosts.contains(&b) {
112 hosts.push(b);
113 }
114 }
115 hosts
116}
117
118pub fn migrate_host_settings(conn: &Connection) {
125 let config = recall::config::Config::load();
126 let mut updates: Vec<(&str, toml_edit::Item)> = Vec::new();
127 let live_hostname = get_hostname();
128
129 let config_hostname = if let Some(ref h) = config.host.hostname {
131 BString::from(h.as_bytes())
132 } else if let Ok(Some(hostname)) = get_setting(conn, "original_hostname") {
133 updates.push(("host.hostname", toml_edit::value(hostname.to_string())));
134 hostname
135 } else {
136 updates.push(("host.hostname", toml_edit::value(live_hostname.to_string())));
137 live_hostname.clone()
138 };
139
140 if config_hostname != live_hostname {
142 let mut aliases = config.host.aliases.clone();
143 let old_str = config_hostname.to_string();
144 if !aliases.contains(&old_str) {
145 aliases.push(old_str);
146 }
147 let alias_array = toml_edit::Array::from_iter(aliases.iter().map(|s| s.as_str()));
148 updates.push(("host.aliases", toml_edit::value(alias_array)));
149 updates.push(("host.hostname", toml_edit::value(live_hostname.to_string())));
150 }
151
152 if config.host.machine_id.is_none() {
154 let id = rand::random::<u64>();
155 let id = id & i64::MAX as u64;
159 updates.push(("host.machine_id", toml_edit::value(id as i64)));
160 }
161
162 if !updates.is_empty()
163 && let Err(e) = recall::config::Config::update_default_config(&updates)
164 {
165 log::warn!("Failed to migrate host settings to config: {e}");
166 return;
167 }
168
169 if config.host.hostname.is_none() {
171 let _ = conn.execute("DELETE FROM settings WHERE key = 'original_hostname'", []);
172 }
173}
174pub fn pxh_data_dir() -> Option<PathBuf> {
177 let home = home::home_dir()?;
178 let xdg_data =
179 env::var("XDG_DATA_HOME").map(PathBuf::from).unwrap_or_else(|_| home.join(".local/share"));
180 let xdg_dir = xdg_data.join("pxh");
181 if xdg_dir.exists() {
182 return Some(xdg_dir);
183 }
184 let legacy = home.join(".pxh");
185 if legacy.exists() {
186 return Some(legacy);
187 }
188 Some(xdg_dir)
189}
190
191pub fn pxh_config_dir() -> Option<PathBuf> {
194 let home = home::home_dir()?;
195 let xdg_config =
196 env::var("XDG_CONFIG_HOME").map(PathBuf::from).unwrap_or_else(|_| home.join(".config"));
197 let xdg_dir = xdg_config.join("pxh");
198 if xdg_dir.exists() {
199 return Some(xdg_dir);
200 }
201 let legacy = home.join(".pxh");
202 if legacy.exists() {
203 return Some(legacy);
204 }
205 Some(xdg_dir)
206}
207
208pub fn default_db_path() -> Option<PathBuf> {
210 Some(pxh_data_dir()?.join("pxh.db"))
211}
212
213pub fn initialize_base_schema(conn: &Connection) -> Result<(), Box<dyn std::error::Error>> {
217 conn.execute_batch(include_str!("base_schema.sql"))?;
218 conn.create_scalar_function("regexp", 2, FunctionFlags::SQLITE_DETERMINISTIC, move |ctx| {
219 assert_eq!(ctx.len(), 2, "called with unexpected number of arguments");
220 let regexp: Arc<Regex> = ctx
221 .get_or_create_aux(0, |vr| -> Result<_, BoxError> { Ok(Regex::new(vr.as_str()?)?) })?;
222 let is_match = {
223 let text = ctx.get_raw(1).as_bytes().map_err(|e| Error::UserFunctionError(e.into()))?;
224 regexp.is_match(text)
225 };
226 Ok(is_match)
227 })?;
228 Ok(())
229}
230
231pub fn run_schema_migrations(conn: &Connection) -> Result<(), Box<dyn std::error::Error>> {
233 let version: i32 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
234
235 if version < 1 {
236 match conn.execute("ALTER TABLE command_history ADD COLUMN machine_id INTEGER", []) {
238 Ok(_) => {}
239 Err(e) if e.to_string().contains("duplicate column name") => {}
240 Err(e) => return Err(e.into()),
241 }
242 conn.pragma_update(None, "user_version", 1)?;
243 }
244
245 Ok(())
246}
247
248pub fn sqlite_connection(path: &Option<PathBuf>) -> Result<Connection, Box<dyn std::error::Error>> {
249 let path = path.as_ref().ok_or("Database not defined; use --db or PXH_DB_PATH")?;
250 if let Some(parent) = path.parent() {
251 let resolved = resolve_through_symlinks(parent);
254 std::fs::create_dir_all(resolved)?;
255 }
256 let conn = Connection::open(path)?;
257
258 use std::os::unix::fs::PermissionsExt;
260 if let Ok(metadata) = std::fs::metadata(path) {
261 let mode = metadata.permissions().mode();
262 if mode & 0o077 != 0 {
263 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
264 }
265 }
266
267 conn.busy_timeout(Duration::from_millis(5000))?;
268 conn.pragma_update(None, "journal_mode", "WAL")?;
269 conn.pragma_update(None, "temp_store", "MEMORY")?;
270 conn.pragma_update(None, "cache_size", "16777216")?;
271 conn.pragma_update(None, "synchronous", "NORMAL")?;
272
273 initialize_base_schema(&conn)?;
274 run_schema_migrations(&conn)?;
275
276 Ok(conn)
277}
278
279#[derive(Debug, Default, Serialize, Deserialize)]
280pub struct Invocation {
281 pub command: BString,
282 pub shellname: String,
283 pub working_directory: Option<BString>,
284 pub hostname: Option<BString>,
285 pub username: Option<BString>,
286 pub exit_status: Option<i64>,
287 pub start_unix_timestamp: Option<i64>,
288 pub end_unix_timestamp: Option<i64>,
289 pub session_id: i64,
290 #[serde(default)]
291 pub machine_id: Option<u64>,
292}
293
294impl Invocation {
295 fn sameish(&self, other: &Self) -> bool {
296 self.command == other.command && self.start_unix_timestamp == other.start_unix_timestamp
297 }
298
299 pub fn insert(&self, tx: &Transaction) -> Result<(), Box<dyn std::error::Error>> {
300 tx.execute(
301 r#"
302INSERT OR IGNORE INTO command_history (
303 session_id,
304 full_command,
305 shellname,
306 hostname,
307 username,
308 working_directory,
309 exit_status,
310 start_unix_timestamp,
311 end_unix_timestamp,
312 machine_id
313)
314VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
315 (
316 self.session_id,
317 self.command.as_slice(),
318 self.shellname.clone(),
319 self.hostname.as_ref().map(|v| v.to_vec()),
320 self.username.as_ref().map(|v| v.to_vec()),
321 self.working_directory.as_ref().map(|v| v.to_vec()),
322 self.exit_status,
323 self.start_unix_timestamp,
324 self.end_unix_timestamp,
325 self.machine_id.map(|id| id as i64),
326 ),
327 )?;
328
329 Ok(())
330 }
331}
332
333fn generate_import_session_id(histfile: &Path) -> i64 {
336 if let Ok(st) = std::fs::metadata(histfile) {
337 ((st.ino() << 16) | st.dev()) as i64
338 } else {
339 (rand::random::<u64>() >> 1) as i64
340 }
341}
342
343pub fn import_zsh_history(
344 histfile: &Path,
345 hostname: Option<BString>,
346 username: Option<BString>,
347) -> Result<Vec<Invocation>, Box<dyn std::error::Error>> {
348 let mut f = File::open(histfile)?;
349 let mut buf = Vec::new();
350 let _ = f.read_to_end(&mut buf)?;
351 let username = username
352 .or_else(|| uzers::get_current_username().map(|v| BString::from(v.into_vec())))
353 .unwrap_or_else(|| BString::from("unknown"));
354 let hostname = hostname.unwrap_or_else(get_hostname);
355 let buf_iter = buf.split(|&ch| ch == b'\n');
356
357 let mut ret = vec![];
358 let mut skipped = 0usize;
359 let session_id = generate_import_session_id(histfile);
360 for (line_num, line) in buf_iter.enumerate() {
361 let Some((fields, command)) = line.splitn(2, |&ch| ch == b';').collect_tuple() else {
362 continue;
363 };
364 let Some((_skip, start_time, duration_seconds)) =
365 fields.splitn(3, |&ch| ch == b':').collect_tuple()
366 else {
367 continue;
368 };
369 let start_unix_timestamp =
370 match str::from_utf8(&start_time[1..]).ok().and_then(|s| s.parse::<i64>().ok()) {
371 Some(ts) => ts,
372 None => {
373 eprintln!(
374 "warning: {}: skipping line {}: bad timestamp {:?}",
375 histfile.display(),
376 line_num + 1,
377 BString::from(start_time),
378 );
379 skipped += 1;
380 continue;
381 }
382 };
383 let duration =
384 match str::from_utf8(duration_seconds).ok().and_then(|s| s.parse::<i64>().ok()) {
385 Some(d) => d,
386 None => {
387 eprintln!(
388 "warning: {}: skipping line {}: bad duration {:?}",
389 histfile.display(),
390 line_num + 1,
391 BString::from(duration_seconds),
392 );
393 skipped += 1;
394 continue;
395 }
396 };
397 let invocation = Invocation {
398 command: BString::from(command),
399 shellname: "zsh".into(),
400 hostname: Some(BString::from(hostname.as_bytes())),
401 username: Some(BString::from(username.as_bytes())),
402 start_unix_timestamp: Some(start_unix_timestamp),
403 end_unix_timestamp: Some(start_unix_timestamp + duration),
404 session_id,
405 ..Default::default()
406 };
407
408 ret.push(invocation);
409 }
410
411 if skipped > 0 {
412 eprintln!("warning: {}: skipped {skipped} malformed line(s)", histfile.display());
413 }
414
415 Ok(dedup_invocations(ret))
416}
417
418pub fn import_bash_history(
419 histfile: &Path,
420 hostname: Option<BString>,
421 username: Option<BString>,
422) -> Result<Vec<Invocation>, Box<dyn std::error::Error>> {
423 let mut f = File::open(histfile)?;
424 let mut buf = Vec::new();
425 let _ = f.read_to_end(&mut buf)?;
426 let username = username
427 .or_else(|| uzers::get_current_username().map(|v| BString::from(v.as_bytes())))
428 .unwrap_or_else(|| BString::from("unknown"));
429 let hostname = hostname.unwrap_or_else(get_hostname);
430 let buf_iter = buf.split(|&ch| ch == b'\n').filter(|l| !l.is_empty());
431
432 let mut ret = vec![];
433 let session_id = generate_import_session_id(histfile);
434 let mut last_ts = None;
435 for line in buf_iter {
436 if line[0] == b'#'
437 && let Ok(ts) = str::parse::<i64>(str::from_utf8(&line[1..]).unwrap_or("0"))
438 {
439 if ts > 0 {
440 last_ts = Some(ts);
441 }
442 continue;
443 }
444 let invocation = Invocation {
445 command: BString::from(line),
446 shellname: "bash".into(),
447 hostname: Some(BString::from(hostname.as_bytes())),
448 username: Some(BString::from(username.as_bytes())),
449 start_unix_timestamp: last_ts,
450 session_id,
451 ..Default::default()
452 };
453
454 ret.push(invocation);
455 }
456
457 Ok(dedup_invocations(ret))
458}
459
460pub fn import_json_history(histfile: &Path) -> Result<Vec<Invocation>, Box<dyn std::error::Error>> {
461 let f = File::open(histfile)?;
462 let reader = BufReader::new(f);
463 Ok(serde_json::from_reader(reader)?)
464}
465
466fn dedup_invocations(invocations: Vec<Invocation>) -> Vec<Invocation> {
467 let mut it = invocations.into_iter();
468 let Some(first) = it.next() else { return vec![] };
469 let mut ret = vec![first];
470 for elem in it {
471 if !elem.sameish(ret.last().unwrap()) {
472 ret.push(elem);
473 }
474 }
475 ret
476}
477
478impl Invocation {
479 pub fn from_row(row: &Row) -> Result<Self, Error> {
480 Ok(Invocation {
481 session_id: row.get("session_id")?,
482 command: BString::from(row.get::<_, Vec<u8>>("full_command")?),
483 shellname: row.get("shellname")?,
484 working_directory: row
485 .get::<_, Option<Vec<u8>>>("working_directory")?
486 .map(BString::from),
487 hostname: row.get::<_, Option<Vec<u8>>>("hostname")?.map(BString::from),
488 username: row.get::<_, Option<Vec<u8>>>("username")?.map(BString::from),
489 exit_status: row.get("exit_status")?,
490 start_unix_timestamp: row.get("start_unix_timestamp")?,
491 end_unix_timestamp: row.get("end_unix_timestamp")?,
492 machine_id: row.get::<_, Option<i64>>("machine_id").ok().flatten().map(|v| v as u64),
493 })
494 }
495}
496
497#[derive(Debug, Eq, PartialEq, Serialize, Deserialize, Clone)]
501#[serde(untagged)]
502enum PrettyExportString {
503 Readable(String),
504 Encoded(Vec<u8>),
505}
506
507impl From<&[u8]> for PrettyExportString {
508 fn from(bytes: &[u8]) -> Self {
509 match str::from_utf8(bytes) {
510 Ok(v) => Self::Readable(v.to_string()),
511 _ => Self::Encoded(bytes.to_vec()),
512 }
513 }
514}
515
516impl From<Option<&Vec<u8>>> for PrettyExportString {
517 fn from(bytes: Option<&Vec<u8>>) -> Self {
518 match bytes {
519 Some(v) => match str::from_utf8(v.as_slice()) {
520 Ok(s) => Self::Readable(s.to_string()),
521 _ => Self::Encoded(v.to_vec()),
522 },
523 None => Self::Readable(String::new()),
524 }
525 }
526}
527
528impl Invocation {
529 fn to_json_export(&self) -> serde_json::Value {
530 serde_json::json!({
531 "session_id": self.session_id,
532 "command": PrettyExportString::from(self.command.as_slice()),
533 "shellname": self.shellname,
534 "working_directory": self.working_directory.as_ref().map_or(
535 PrettyExportString::Readable(String::new()),
536 |b| PrettyExportString::from(b.as_slice())
537 ),
538 "hostname": self.hostname.as_ref().map_or(
539 PrettyExportString::Readable(String::new()),
540 |b| PrettyExportString::from(b.as_slice())
541 ),
542 "username": self.username.as_ref().map_or(
543 PrettyExportString::Readable(String::new()),
544 |b| PrettyExportString::from(b.as_slice())
545 ),
546 "exit_status": self.exit_status,
547 "start_unix_timestamp": self.start_unix_timestamp,
548 "end_unix_timestamp": self.end_unix_timestamp,
549 })
550 }
551}
552
553pub fn json_export(rows: &[Invocation]) -> Result<(), Box<dyn std::error::Error>> {
554 let json_values: Vec<serde_json::Value> = rows.iter().map(|r| r.to_json_export()).collect();
555 serde_json::to_writer(io::stdout(), &json_values)?;
556 Ok(())
557}
558
559struct QueryResultColumnDisplayer {
562 header: &'static str,
563 header_style: &'static str,
564 displayer: Box<dyn Fn(&Invocation) -> prettytable::Cell>,
565}
566
567fn time_display_helper(t: Option<i64>) -> String {
568 t.and_then(|t| Local.timestamp_opt(t, 0).single())
572 .map(|t| t.format(TIME_FORMAT).to_string())
573 .unwrap_or_else(|| "n/a".to_string())
574}
575
576fn binary_display_helper(v: &BString) -> String {
577 String::from_utf8_lossy(v.as_slice()).to_string()
578}
579
580fn displayers() -> HashMap<&'static str, QueryResultColumnDisplayer> {
581 let mut ret = HashMap::new();
582 ret.insert(
583 "command",
584 QueryResultColumnDisplayer {
585 header: "Command",
586 header_style: "Fw",
587 displayer: Box::new(|row| {
588 prettytable::Cell::new(&binary_display_helper(&row.command)).style_spec("Fw")
589 }),
590 },
591 );
592 ret.insert(
593 "start_time",
594 QueryResultColumnDisplayer {
595 header: "Start",
596 header_style: "Fg",
597 displayer: Box::new(|row| {
598 prettytable::Cell::new(&time_display_helper(row.start_unix_timestamp))
599 .style_spec("Fg")
600 }),
601 },
602 );
603 ret.insert(
604 "end_time",
605 QueryResultColumnDisplayer {
606 header: "End",
607 header_style: "Fg",
608 displayer: Box::new(|row| {
609 prettytable::Cell::new(&time_display_helper(row.end_unix_timestamp))
610 .style_spec("Fg")
611 }),
612 },
613 );
614 ret.insert(
615 "duration",
616 QueryResultColumnDisplayer {
617 header: "Duration",
618 header_style: "Fm",
619 displayer: Box::new(|row| {
620 let text = match (row.start_unix_timestamp, row.end_unix_timestamp) {
621 (Some(start), Some(end)) => format!("{}s", end - start),
622 _ => "n/a".into(),
623 };
624 prettytable::Cell::new(&text).style_spec("Fm")
625 }),
626 },
627 );
628 ret.insert(
629 "status",
630 QueryResultColumnDisplayer {
631 header: "Status",
632 header_style: "Fr",
633 displayer: Box::new(|row| match row.exit_status {
634 Some(0) => prettytable::Cell::new("0").style_spec("Fg"),
635 Some(s) => prettytable::Cell::new(&s.to_string()).style_spec("Fr"),
636 None => prettytable::Cell::new("n/a").style_spec("Fd"),
637 }),
638 },
639 );
640 ret.insert(
643 "session",
644 QueryResultColumnDisplayer {
645 header: "Session",
646 header_style: "Fc",
647 displayer: Box::new(|row| {
648 prettytable::Cell::new(&format!("{:x}", row.session_id)).style_spec("Fc")
649 }),
650 },
651 );
652 ret.insert(
656 "context",
657 QueryResultColumnDisplayer {
658 header: "Context",
659 header_style: "bFb",
660 displayer: Box::new(|row| {
661 let current_hostname = get_hostname();
662 let row_hostname = row.hostname.clone().unwrap_or_default();
663 let mut ret = String::new();
664 if current_hostname != row_hostname {
665 write!(ret, "{row_hostname}:").unwrap_or_default();
666 }
667 let current_directory = env::current_dir().unwrap_or_default();
668 ret.push_str(&row.working_directory.as_ref().map_or_else(String::new, |v| {
669 let v = String::from_utf8_lossy(v.as_slice()).to_string();
670 if v == current_directory.to_string_lossy() { String::from(".") } else { v }
671 }));
672
673 prettytable::Cell::new(&ret).style_spec("bFb")
674 }),
675 },
676 );
677
678 ret
679}
680
681pub fn present_results_human_readable(
682 fields: &[&str],
683 rows: &[Invocation],
684 suppress_headers: bool,
685) -> Result<(), Box<dyn std::error::Error>> {
686 let displayers = displayers();
687 let mut table = prettytable::Table::new();
688 table.set_format(*prettytable::format::consts::FORMAT_CLEAN);
689
690 if !suppress_headers {
691 let mut title_row = prettytable::Row::empty();
692 for field in fields {
693 let Some(d) = displayers.get(field) else {
694 return Err(Box::from(format!("Invalid 'show' field: {field}")));
695 };
696
697 title_row.add_cell(prettytable::Cell::new(d.header).style_spec(d.header_style));
698 }
699 table.set_titles(title_row);
700 }
701
702 for row in rows.iter() {
703 let is_failed = matches!(row.exit_status, Some(s) if s != 0);
704 let mut display_row = prettytable::Row::empty();
705 for field in fields {
706 let cell = (displayers[field].displayer)(row);
707 if is_failed {
708 display_row.add_cell(prettytable::Cell::new(&cell.get_content()).style_spec("Fr"));
709 } else {
710 display_row.add_cell(cell);
711 }
712 }
713 table.add_row(display_row);
714 }
715 table.printstd();
716 Ok(())
717}
718
719pub fn atomically_remove_lines_from_file(
722 input_filepath: &PathBuf,
723 contraband: &str,
724) -> Result<(), Box<dyn std::error::Error>> {
725 let original_perms = std::fs::metadata(input_filepath)?.permissions();
726 let input_file = File::open(input_filepath)?;
727 let mut input_reader = BufReader::new(input_file);
728
729 let parent = input_filepath.parent().unwrap_or(Path::new("."));
730 let temp_file = tempfile::NamedTempFile::new_in(parent)?;
731 let mut output_writer = BufWriter::new(&temp_file);
732
733 input_reader.for_byte_line_with_terminator(|line| {
734 if !line.contains_str(contraband) {
735 output_writer.write_all(line)?;
736 }
737 Ok(true)
738 })?;
739
740 output_writer.flush()?;
741 drop(output_writer);
742 temp_file.persist(input_filepath)?;
743 std::fs::set_permissions(input_filepath, original_perms)?;
744 Ok(())
745}
746
747pub fn atomically_remove_matching_lines_from_file(
750 input_filepath: &Path,
751 contraband_items: &[&str],
752) -> Result<(), Box<dyn std::error::Error>> {
753 use std::collections::HashSet;
754
755 let original_perms = std::fs::metadata(input_filepath)?.permissions();
756 let contraband_set: HashSet<&str> = contraband_items.iter().copied().collect();
757
758 let input_file = File::open(input_filepath)?;
759 let mut input_reader = BufReader::new(input_file);
760
761 let parent = input_filepath.parent().unwrap_or(Path::new("."));
762 let temp_file = tempfile::NamedTempFile::new_in(parent)?;
763 let mut output_writer = BufWriter::new(&temp_file);
764
765 input_reader.for_byte_line_with_terminator(|line| {
766 let line_str = line.to_str_lossy();
767 let trimmed = line_str.trim();
768 if !contraband_set.contains(trimmed) {
769 output_writer.write_all(line)?;
770 }
771 Ok(true)
772 })?;
773
774 output_writer.flush()?;
775 drop(output_writer);
776 temp_file.persist(input_filepath)?;
777 std::fs::set_permissions(input_filepath, original_perms)?;
778 Ok(())
779}
780
781pub mod helpers {
783 use std::path::{Path, PathBuf};
784
785 pub fn parse_ssh_command(ssh_cmd: &str) -> (String, Vec<String>) {
788 if !ssh_cmd.contains(char::is_whitespace) {
790 return (ssh_cmd.to_string(), vec![]);
791 }
792
793 let mut cmd = String::new();
795 let mut args = Vec::new();
796 let mut current = String::new();
797 let mut in_quotes = false;
798 let mut quote_char = '\0';
799 let mut is_first = true;
800 let mut chars = ssh_cmd.chars().peekable();
801
802 while let Some(ch) = chars.next() {
803 match ch {
804 '"' | '\'' if !in_quotes => {
805 in_quotes = true;
806 quote_char = ch;
807 }
808 '"' | '\'' if in_quotes && ch == quote_char => {
809 in_quotes = false;
810 quote_char = '\0';
811 }
812 ' ' | '\t' if !in_quotes => {
813 if !current.is_empty() {
814 if is_first {
815 cmd = current.clone();
816 is_first = false;
817 } else {
818 args.push(current.clone());
819 }
820 current.clear();
821 }
822 }
823 '\\' if chars.peek().is_some() => {
824 if let Some(next_ch) = chars.next() {
826 current.push(next_ch);
827 }
828 }
829 _ => {
830 current.push(ch);
831 }
832 }
833 }
834
835 if !current.is_empty() {
837 if is_first {
838 cmd = current;
839 } else {
840 args.push(current);
841 }
842 }
843
844 (cmd, args)
845 }
846
847 fn remote_pxh_candidates(configured_path: &str) -> Vec<String> {
850 let mut candidates = Vec::new();
851
852 if configured_path != "pxh" {
853 candidates.push(configured_path.to_string());
854 return candidates;
855 }
856
857 if let Some(rel) = get_relative_path_from_home(None, None)
859 && rel != "pxh"
860 {
861 candidates.push(format!("$HOME/{rel}"));
862 }
863
864 for p in [
866 "$HOME/.cargo/bin/pxh",
867 "$HOME/bin/pxh",
868 "$HOME/.local/bin/pxh",
869 "/usr/local/bin/pxh",
870 "/usr/bin/pxh",
871 ] {
872 if !candidates.contains(&p.to_string()) {
873 candidates.push(p.to_string());
874 }
875 }
876
877 candidates
878 }
879
880 pub fn build_remote_pxh_command(configured_path: &str, args: &str) -> String {
884 let candidates = remote_pxh_candidates(configured_path);
885
886 if candidates.len() == 1 {
887 return format!("{} {args}", candidates[0]);
888 }
889
890 let checks: Vec<String> =
892 candidates.iter().map(|p| format!("[ -x \"{p}\" ] && exec \"{p}\" {args}")).collect();
893 format!(
894 "sh -c '{}; echo \"pxh: not found on remote host\" >&2; exit 127'",
895 checks.join("; ")
896 )
897 }
898
899 pub fn get_relative_path_from_home(
903 exe_override: Option<&Path>,
904 home_override: Option<&Path>,
905 ) -> Option<String> {
906 let exe = match exe_override {
907 Some(path) => path.to_path_buf(),
908 None => std::env::current_exe().ok()?,
909 };
910
911 let home = match home_override {
912 Some(path) => path.to_path_buf(),
913 None => home::home_dir()?,
914 };
915
916 exe.strip_prefix(&home).ok().map(|path| path.to_string_lossy().to_string())
917 }
918
919 pub fn default_remote_db_expr() -> String {
922 r#"$(if [ -d "${XDG_DATA_HOME:-$HOME/.local/share}/pxh" ]; then echo "${XDG_DATA_HOME:-$HOME/.local/share}/pxh/pxh.db"; elif [ -d "$HOME/.pxh" ]; then echo "$HOME/.pxh/pxh.db"; else echo "${XDG_DATA_HOME:-$HOME/.local/share}/pxh/pxh.db"; fi)"#.to_string()
923 }
924
925 pub fn determine_is_pxhs(args: &[String]) -> bool {
927 args.first()
928 .and_then(|arg| {
929 PathBuf::from(arg).file_name().map(|name| name.to_string_lossy().contains("pxhs"))
930 })
931 .unwrap_or(false)
932 }
933}
934
935#[doc(hidden)]
937pub mod test_utils {
938 use std::{
939 env,
940 path::{Path, PathBuf},
941 process::Command,
942 };
943
944 use rand::{RngExt, distr::Alphanumeric};
945 use tempfile::TempDir;
946
947 pub fn pxh_path() -> PathBuf {
948 let mut path = std::env::current_exe().unwrap();
949 path.pop(); path.pop(); path.push("pxh");
952 assert!(path.exists(), "pxh binary not found at {:?}", path);
953 path
954 }
955
956 fn generate_random_string(length: usize) -> String {
957 rand::rng().sample_iter(&Alphanumeric).take(length).map(char::from).collect()
958 }
959
960 fn get_standard_path() -> String {
961 Command::new("getconf")
963 .arg("PATH")
964 .output()
965 .ok()
966 .and_then(|output| {
967 if output.status.success() { String::from_utf8(output.stdout).ok() } else { None }
968 })
969 .map(|s| s.trim().to_string())
970 .unwrap_or_else(|| {
971 "/usr/bin:/bin:/usr/sbin:/sbin".to_string()
973 })
974 }
975
976 pub struct PxhTestHelper {
978 _tmpdir: TempDir,
979 pub hostname: String,
980 pub username: String,
981 home_dir: PathBuf,
982 db_path: PathBuf,
983 }
984
985 impl PxhTestHelper {
986 pub fn new() -> Self {
987 let tmpdir = TempDir::new().unwrap();
988 let home_dir = tmpdir.path().to_path_buf();
989 let db_path = home_dir.join(".pxh/pxh.db");
990
991 let pxh_dir = home_dir.join(".pxh");
994 std::fs::create_dir_all(&pxh_dir).unwrap();
995 std::fs::write(pxh_dir.join("config.toml"), "[history]\nignore_patterns = []\n")
996 .unwrap();
997
998 PxhTestHelper {
999 _tmpdir: tmpdir,
1000 hostname: generate_random_string(12),
1001 username: "testuser".to_string(),
1002 home_dir,
1003 db_path,
1004 }
1005 }
1006
1007 pub fn with_custom_db_path(mut self, db_path: impl AsRef<Path>) -> Self {
1008 self.db_path = self.home_dir.join(db_path);
1009 self
1010 }
1011
1012 pub fn home_dir(&self) -> &Path {
1014 &self.home_dir
1015 }
1016
1017 pub fn db_path(&self) -> &Path {
1019 &self.db_path
1020 }
1021
1022 pub fn get_full_path(&self) -> String {
1024 format!("{}:{}", pxh_path().parent().unwrap().display(), get_standard_path())
1025 }
1026
1027 pub fn command(&self) -> Command {
1029 let mut cmd = Command::new(pxh_path());
1030
1031 cmd.env_clear();
1033
1034 cmd.env("HOME", &self.home_dir);
1036 cmd.env("PXH_DB_PATH", &self.db_path);
1037 cmd.env("PXH_HOSTNAME", &self.hostname);
1038 cmd.env("USER", &self.username);
1039 cmd.env("PATH", self.get_full_path());
1040
1041 if let Ok(profile_file) = env::var("LLVM_PROFILE_FILE") {
1043 cmd.env("LLVM_PROFILE_FILE", profile_file);
1044 }
1045 if let Ok(llvm_cov) = env::var("CARGO_LLVM_COV") {
1046 cmd.env("CARGO_LLVM_COV", llvm_cov);
1047 }
1048
1049 cmd
1050 }
1051
1052 pub fn command_with_args(&self, args: &[&str]) -> Command {
1054 let mut cmd = self.command();
1055 cmd.args(args);
1056 cmd
1057 }
1058
1059 pub fn shell_command(&self, shell: &str) -> Command {
1061 let mut cmd = Command::new(shell);
1062
1063 cmd.arg("-i");
1065 cmd.env_clear();
1066
1067 cmd.env("HOME", &self.home_dir);
1069 cmd.env("PXH_DB_PATH", &self.db_path);
1070 cmd.env("PXH_HOSTNAME", &self.hostname);
1071 cmd.env("PATH", self.get_full_path());
1072
1073 cmd.env("USER", &self.username);
1075 cmd.env("SHELL", shell);
1076
1077 cmd.env("BASH_ENV", self.home_dir.join(".bashrc"));
1079
1080 cmd
1081 }
1082 }
1083
1084 impl Default for PxhTestHelper {
1085 fn default() -> Self {
1086 Self::new()
1087 }
1088 }
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093 use super::*;
1094
1095 fn test_connection() -> Connection {
1097 let conn = Connection::open_in_memory().unwrap();
1098 initialize_base_schema(&conn).unwrap();
1099 run_schema_migrations(&conn).unwrap();
1100 conn
1101 }
1102
1103 #[test]
1104 fn test_resolve_hostname_from_config() {
1105 let conn = test_connection();
1106
1107 let mut config = recall::config::Config::default();
1108 config.host.hostname = Some("from-config".to_string());
1109 set_setting(&conn, "original_hostname", &BString::from("from-db")).unwrap();
1110
1111 let result = resolve_hostname(&config, &conn);
1112 assert_eq!(result, BString::from("from-config"));
1113 }
1114
1115 #[test]
1116 fn test_resolve_hostname_from_db() {
1117 let conn = test_connection();
1118
1119 let config = recall::config::Config::default();
1120 set_setting(&conn, "original_hostname", &BString::from("from-db")).unwrap();
1121
1122 let result = resolve_hostname(&config, &conn);
1123 assert_eq!(result, BString::from("from-db"));
1124 }
1125
1126 #[test]
1127 fn test_resolve_hostname_live_fallback() {
1128 let conn = test_connection();
1129
1130 let config = recall::config::Config::default();
1131 let result = resolve_hostname(&config, &conn);
1132 assert_eq!(result, get_hostname());
1133 }
1134
1135 #[test]
1136 fn test_effective_host_set_no_aliases() {
1137 let config = recall::config::Config::default();
1138 let hosts = effective_host_set(&config);
1139 assert_eq!(hosts, vec![get_hostname()]);
1140 }
1141
1142 #[test]
1143 fn test_effective_host_set_with_aliases() {
1144 let mut config = recall::config::Config::default();
1145 config.host.aliases = vec!["old-host".to_string(), "other-host".to_string()];
1146 let hosts = effective_host_set(&config);
1147 assert_eq!(hosts.len(), 3);
1148 assert_eq!(hosts[0], get_hostname());
1149 assert_eq!(hosts[1], BString::from("old-host"));
1150 assert_eq!(hosts[2], BString::from("other-host"));
1151 }
1152
1153 #[test]
1154 fn test_effective_host_set_dedup() {
1155 let mut config = recall::config::Config::default();
1156 let current = get_hostname().to_string();
1157 config.host.aliases = vec![current, "other".to_string()];
1158 let hosts = effective_host_set(&config);
1159 assert_eq!(hosts.len(), 2);
1160 }
1161
1162 #[test]
1163 fn test_migration_fresh_database() {
1164 let conn = Connection::open_in_memory().unwrap();
1165 conn.execute_batch(include_str!("base_schema.sql")).unwrap();
1166
1167 let version: i32 = conn.pragma_query_value(None, "user_version", |row| row.get(0)).unwrap();
1168 assert_eq!(version, 0);
1169
1170 run_schema_migrations(&conn).unwrap();
1171
1172 let version: i32 = conn.pragma_query_value(None, "user_version", |row| row.get(0)).unwrap();
1173 assert_eq!(version, 1);
1174
1175 conn.execute(
1177 "INSERT INTO command_history (session_id, full_command, shellname, machine_id) VALUES (1, X'6C73', 'bash', 42)",
1178 [],
1179 )
1180 .unwrap();
1181 }
1182
1183 #[test]
1184 fn test_migration_idempotent() {
1185 let conn = test_connection();
1186 run_schema_migrations(&conn).unwrap();
1188
1189 let version: i32 = conn.pragma_query_value(None, "user_version", |row| row.get(0)).unwrap();
1190 assert_eq!(version, 1);
1191 }
1192
1193 #[test]
1194 fn test_migration_legacy_database_with_machine_id() {
1195 let conn = Connection::open_in_memory().unwrap();
1197 conn.execute_batch(include_str!("base_schema.sql")).unwrap();
1198 conn.execute("ALTER TABLE command_history ADD COLUMN machine_id INTEGER", []).unwrap();
1199
1200 let version: i32 = conn.pragma_query_value(None, "user_version", |row| row.get(0)).unwrap();
1201 assert_eq!(version, 0);
1202
1203 run_schema_migrations(&conn).unwrap();
1205
1206 let version: i32 = conn.pragma_query_value(None, "user_version", |row| row.get(0)).unwrap();
1207 assert_eq!(version, 1);
1208 }
1209}