diff options
Diffstat (limited to 'crates/turtle/src/history/mod.rs')
| -rw-r--r-- | crates/turtle/src/history/mod.rs | 300 |
1 files changed, 300 insertions, 0 deletions
diff --git a/crates/turtle/src/history/mod.rs b/crates/turtle/src/history/mod.rs new file mode 100644 index 00000000..10e74d8e --- /dev/null +++ b/crates/turtle/src/history/mod.rs @@ -0,0 +1,300 @@ +use core::fmt::Formatter; +use regex::RegexSet; +use std::env; +use std::fmt::Display; + +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)] +pub struct HistoryId(pub String); + +impl Display for HistoryId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From<String> for HistoryId { + fn from(s: String) -> Self { + Self(s) + } +} + +pub(crate) fn get_hostname() -> String { + env::var("ATUIN_HOST_NAME") + .unwrap_or_else(|_| whoami::hostname().unwrap_or_else(|_| "unknown-host".to_string())) +} + +pub(crate) fn get_username() -> String { + env::var("ATUIN_HOST_USER") + .unwrap_or_else(|_| whoami::username().unwrap_or_else(|_| "unknown-user".to_string())) +} + +/// Returns a pair of the hostname and username, separated by a colon. +#[must_use] +pub fn get_host_user() -> String { + format!("{}:{}", get_hostname(), get_username()) +} + +/// 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: i64, + + /// 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<String>, + + /// Timestamp, which is set when the entry is deleted, allowing a soft delete. + pub deleted_at: Option<OffsetDateTime>, +} + +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<String>) -> Option<String> { + 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: i64, + session: Option<String>, + hostname: Option<String>, + author: Option<String>, + intent: Option<String>, + deleted_at: Option<OffsetDateTime>, + ) -> Self { + let session = session + .or_else(|| env::var("ATUIN_SESSION").ok()) + .unwrap_or_else(|| uuid_v7().as_simple().to_string()); + let hostname = hostname.unwrap_or_else(get_host_user); + 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)); + // } +} |
