use core::fmt::Formatter; use regex::RegexSet; use std::env; use std::fmt::Display; use std::time::Duration; use uuid::Uuid; use turtle_common::utils::uuid_v7; use time::OffsetDateTime; use crate::history::secrets::SECRET_PATTERNS_RE; pub mod builder; mod secrets; const HISTORY_AUTHOR_ENV: &str = "ATUIN_HISTORY_AUTHOR"; const HISTORY_INTENT_ENV: &str = "ATUIN_HISTORY_INTENT"; #[derive(Clone, Debug, Eq, PartialEq, Hash, Copy)] pub struct HistoryId(Uuid); impl Display for HistoryId { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } impl From for HistoryId { fn from(s: String) -> Self { Self(Uuid::parse_str(&s).expect("should be a valid uuid")) } } impl From<&str> for HistoryId { fn from(s: &str) -> Self { Self(Uuid::parse_str(s).expect("should be a valid uuid")) } } /// Client-side history entry. /// /// Client stores data unencrypted, and only encrypts it before sending to the server. /// /// To create a new history entry, use one of the builders: /// - [`History::import()`] to import an entry from the shell history file /// - [`History::capture()`] to capture an entry via hook /// - [`History::from_db()`] to create an instance from the database entry // // ## Implementation Notes // // New fields must be added to `History::{serialize,deserialize}` in a backwards // compatible way (sensible defaults and careful `nfields` handling). #[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)] pub struct History { /// A client-generated ID, used to identify the entry when syncing. /// /// Stored as `client_id` in the database. pub id: HistoryId, /// When the command was run. pub timestamp: OffsetDateTime, /// How long the command took to run. pub duration: Duration, /// The exit code of the command. pub exit: i64, /// The command that was run. pub command: String, /// The current working directory when the command was run. pub cwd: String, /// The session ID, associated with a terminal session. pub session: String, /// The hostname of the machine the command was run on. pub hostname: String, /// Who wrote this command (human user or automation/agent identity). pub author: String, /// Optional rationale for why the command was executed. pub intent: Option, /// Timestamp, which is set when the entry is deleted, allowing a soft delete. pub deleted_at: Option, } impl History { #[must_use] pub fn author_from_hostname(hostname: &str) -> String { hostname .split_once(':') .map_or_else(|| hostname.to_owned(), |(_, user)| user.to_owned()) } fn normalize_optional_field(field: Option) -> Option { field.and_then(|value| { let trimmed = value.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_owned()) } }) } #[expect(clippy::too_many_arguments)] fn new( timestamp: OffsetDateTime, command: String, cwd: String, exit: i64, duration: Duration, session: String, hostname: String, author: Option, intent: Option, deleted_at: Option, ) -> Self { let author = Self::normalize_optional_field(author) .or_else(|| Self::normalize_optional_field(env::var(HISTORY_AUTHOR_ENV).ok())) .unwrap_or_else(|| Self::author_from_hostname(hostname.as_str())); let intent = Self::normalize_optional_field(intent) .or_else(|| Self::normalize_optional_field(env::var(HISTORY_INTENT_ENV).ok())); Self { id: uuid_v7().as_simple().to_string().into(), timestamp, command, cwd, exit, duration, session, hostname, author, intent, deleted_at, } } /// Builder for a history entry that is captured via hook, and sent to the daemon. /// /// This builder is used only at the `start` step of the hook, /// so it doesn't have any fields which are known only after /// the command is finished, such as `exit` or `duration`. /// /// It does, however, include information that can usually be inferred. /// /// This is because the daemon we are sending a request to lacks the context of the command /// /// ## Examples /// ```rust /// use crate::aclient::history::History; /// /// let history: History = History::daemon() /// .timestamp(time::OffsetDateTime::now_utc()) /// .command("ls -la") /// .cwd("/home/user") /// .session("018deb6e8287781f9973ef40e0fde76b") /// .hostname("computer:ellie") /// .build() /// .into(); /// ``` /// /// Command without any required info cannot be captured, which is forced at compile time: /// /// ```compile_fail /// use crate::aclient::history::History; /// /// // this will not compile because `hostname` is missing /// let history: History = History::daemon() /// .timestamp(time::OffsetDateTime::now_utc()) /// .command("ls -la") /// .cwd("/home/user") /// .session("018deb6e8287781f9973ef40e0fde76b") /// .build() /// .into(); /// ``` pub fn daemon() -> builder::HistoryDaemonCaptureBuilder { builder::HistoryDaemonCapture::builder() } #[doc(hidden)] pub fn from_db() -> builder::HistoryFromDbBuilder { builder::HistoryFromDb::builder() } pub fn should_save(&self, filter: SettingsFilter<'_>) -> bool { !(self.command.is_empty() || filter.history.is_match(&self.command) || filter.cwd.is_match(&self.cwd) || (filter.secrets && SECRET_PATTERNS_RE.is_match(&self.command))) } } #[derive(Debug, Copy, Clone)] pub struct SettingsFilter<'a> { pub history: &'a RegexSet, pub cwd: &'a RegexSet, pub secrets: bool, } #[cfg(test)] mod tests { // use regex::RegexSet; // // use crate::history::History; // // Test that we don't save history where necessary // #[test] // fn privacy_test() { // let settings = Settings { // cwd_filter: RegexSet::new(["^/supasecret"]).unwrap(), // history_filter: RegexSet::new(["^psql"]).unwrap(), // ..Settings::default() // }; // // let normal_command: History = History::daemon() // .timestamp(time::OffsetDateTime::now_utc()) // .command("echo foo") // .cwd("/") // .build() // .into(); // // let with_space: History = History::daemon() // .timestamp(time::OffsetDateTime::now_utc()) // .command(" echo bar") // .cwd("/") // .build() // .into(); // // let empty: History = History::daemon() // .timestamp(time::OffsetDateTime::now_utc()) // .command("") // .cwd("/") // .build() // .into(); // // let stripe_key: History = History::daemon() // .timestamp(time::OffsetDateTime::now_utc()) // .command("curl foo.com/bar?key=sk_test_1234567890abcdefghijklmnop") // .cwd("/") // .build() // .into(); // // let secret_dir: History = History::daemon() // .timestamp(time::OffsetDateTime::now_utc()) // .command("echo ohno") // .cwd("/supasecret") // .build() // .into(); // // let with_psql: History = History::daemon() // .timestamp(time::OffsetDateTime::now_utc()) // .command("psql") // .cwd("/supasecret") // .build() // .into(); // // assert!(normal_command.should_save(&settings)); // assert!(!with_space.should_save(&settings)); // assert!(!empty.should_save(&settings)); // assert!(!stripe_key.should_save(&settings)); // assert!(!secret_dir.should_save(&settings)); // assert!(!with_psql.should_save(&settings)); // } // // #[test] // fn disable_secrets() { // let settings = Settings { // secrets_filter: false, // ..Settings::new().unwrap() // }; // // let stripe_key: History = History::capture() // .timestamp(time::OffsetDateTime::now_utc()) // .command("curl foo.com/bar?key=sk_test_1234567890abcdefghijklmnop") // .cwd("/") // .build() // .into(); // // assert!(stripe_key.should_save(&settings)); // } }