diff options
Diffstat (limited to '')
| -rw-r--r-- | crates/daemon/src/aclient/database/mod.rs | 19 | ||||
| -rw-r--r-- | crates/daemon/src/aclient/history/builder.rs | 154 | ||||
| -rw-r--r-- | crates/daemon/src/aclient/history/mod.rs | 329 | ||||
| -rw-r--r-- | crates/daemon/src/aclient/history/store.rs | 4 | ||||
| -rw-r--r-- | crates/daemon/src/aclient/mod.rs | 11 | ||||
| -rw-r--r-- | crates/daemon/src/aclient/ordering.rs | 3 | ||||
| -rw-r--r-- | crates/daemon/src/aclient/utils.rs | 15 | ||||
| -rw-r--r-- | crates/turtle/src/history/secrets.rs (renamed from crates/daemon/src/aclient/secrets.rs) | 0 |
8 files changed, 35 insertions, 500 deletions
diff --git a/crates/daemon/src/aclient/database/mod.rs b/crates/daemon/src/aclient/database/mod.rs index 5287807c..cdf71065 100644 --- a/crates/daemon/src/aclient/database/mod.rs +++ b/crates/daemon/src/aclient/database/mod.rs @@ -3,7 +3,6 @@ use std::{ str::FromStr, }; -use crate::aclient::utils::setup_db; use fs_err::{self as fs}; use itertools::Itertools; use sql_builder::{SqlBuilder, SqlName, bind::Bind, esc, quote}; @@ -13,19 +12,16 @@ use sqlx::{ }; use time::OffsetDateTime; use tracing::debug; +use turtle::history::{History, HistoryId, get_host_user}; use turtle_common::utils; use uuid::Uuid; use crate::aclient::{ - history::{HistoryId, HistoryStats}, - utils::get_host_user, -}; - -use super::{ - history::History, + history::HistoryStats, ordering, - settings::{FilterMode, SearchMode, Settings}, + settings::{FilterMode, SearchMode}, }; +use crate::aclient::{settings::Settings, utils::setup_db}; #[derive(Clone)] pub(crate) struct Context { @@ -858,10 +854,15 @@ mod test { } async fn new_history_item(db: &mut ClientSqlite, cmd: &str) -> Result<()> { - let mut captured: History = History::capture() + const SESSION: &str = "test"; + const HOSTNAME: &str = "test.host"; + + let mut captured: History = History::daemon() .timestamp(OffsetDateTime::now_utc()) .command(cmd) .cwd("/home/ellie") + .session(SESSION) + .hostname(HOSTNAME) .build() .into(); diff --git a/crates/daemon/src/aclient/history/builder.rs b/crates/daemon/src/aclient/history/builder.rs deleted file mode 100644 index ef52637b..00000000 --- a/crates/daemon/src/aclient/history/builder.rs +++ /dev/null @@ -1,154 +0,0 @@ -use typed_builder::TypedBuilder; - -use super::History; - -/// Builder for a history entry that is imported from shell history. -/// -/// The only two required fields are `timestamp` and `command`. -#[derive(Debug, Clone, TypedBuilder)] -pub(crate) struct HistoryImported { - timestamp: time::OffsetDateTime, - #[builder(setter(into))] - command: String, - #[builder(default = "unknown".into(), setter(into))] - cwd: String, - #[builder(default = -1)] - exit: i64, - #[builder(default = -1)] - duration: i64, - #[builder(default, setter(strip_option, into))] - session: Option<String>, - #[builder(default, setter(strip_option, into))] - hostname: Option<String>, - #[builder(default, setter(strip_option, into))] - author: Option<String>, - #[builder(default, setter(strip_option, into))] - intent: Option<String>, -} - -impl From<HistoryImported> for History { - fn from(imported: HistoryImported) -> Self { - Self::new( - imported.timestamp, - imported.command, - imported.cwd, - imported.exit, - imported.duration, - imported.session, - imported.hostname, - imported.author, - imported.intent, - None, - ) - } -} - -/// Builder for a history entry that is captured via hook. -/// -/// 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`. -#[derive(Debug, Clone, TypedBuilder)] -pub struct HistoryCaptured { - timestamp: time::OffsetDateTime, - #[builder(setter(into))] - command: String, - #[builder(setter(into))] - cwd: String, - #[builder(default, setter(strip_option, into))] - author: Option<String>, - #[builder(default, setter(strip_option, into))] - intent: Option<String>, -} - -impl From<HistoryCaptured> for History { - fn from(captured: HistoryCaptured) -> Self { - Self::new( - captured.timestamp, - captured.command, - captured.cwd, - -1, - -1, - None, - None, - captured.author, - captured.intent, - None, - ) - } -} - -/// Builder for a history entry that is loaded from the database. -/// -/// All fields are required, as they are all present in the database. -#[derive(Debug, Clone, TypedBuilder)] -pub(crate) struct HistoryFromDb { - id: String, - timestamp: time::OffsetDateTime, - command: String, - cwd: String, - exit: i64, - duration: i64, - session: String, - hostname: String, - author: String, - intent: Option<String>, - deleted_at: Option<time::OffsetDateTime>, -} - -impl From<HistoryFromDb> for History { - fn from(from_db: HistoryFromDb) -> Self { - Self { - id: from_db.id.into(), - timestamp: from_db.timestamp, - exit: from_db.exit, - command: from_db.command, - cwd: from_db.cwd, - duration: from_db.duration, - session: from_db.session, - hostname: from_db.hostname, - author: from_db.author, - intent: from_db.intent, - deleted_at: from_db.deleted_at, - } - } -} - -/// Builder for a history entry that is captured via hook and sent to the daemon -/// -/// This builder is similar to Capture, but we just require more information up front. -/// For the old setup, we could just rely on `History::new` to read some of the missing -/// data. This is no longer the case. -#[derive(Debug, Clone, TypedBuilder)] -pub(crate) struct HistoryDaemonCapture { - timestamp: time::OffsetDateTime, - #[builder(setter(into))] - command: String, - #[builder(setter(into))] - cwd: String, - #[builder(setter(into))] - session: String, - #[builder(setter(into))] - hostname: String, - #[builder(default, setter(strip_option, into))] - author: Option<String>, - #[builder(default, setter(strip_option, into))] - intent: Option<String>, -} - -impl From<HistoryDaemonCapture> for History { - fn from(captured: HistoryDaemonCapture) -> Self { - Self::new( - captured.timestamp, - captured.command, - captured.cwd, - -1, - -1, - Some(captured.session), - Some(captured.hostname), - captured.author, - captured.intent, - None, - ) - } -} diff --git a/crates/daemon/src/aclient/history/mod.rs b/crates/daemon/src/aclient/history/mod.rs index 527d1bb6..ea71dcf4 100644 --- a/crates/daemon/src/aclient/history/mod.rs +++ b/crates/daemon/src/aclient/history/mod.rs @@ -5,17 +5,15 @@ use rmp::decode::ValueReadError; use rmp::{Marker, decode::Bytes}; use std::env; use std::fmt::Display; +use turtle::history::History; use turtle_common::record::DecryptedData; use turtle_common::utils::uuid_v7; use eyre::{Result, bail, eyre}; -use crate::aclient::secrets::SECRET_PATTERNS_RE; -use crate::aclient::utils::get_host_user; use time::OffsetDateTime; -mod builder; pub(crate) mod store; pub(crate) const HISTORY_VERSION_V0: &str = "v0"; @@ -27,72 +25,6 @@ pub(crate) const HISTORY_TAG: &str = "history"; 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) - } -} - -/// 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>, -} - #[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)] pub(crate) struct HistoryStats { /// The command that was ran after this one in the session @@ -113,63 +45,17 @@ pub(crate) struct HistoryStats { pub(crate) duration_over_time: Vec<(String, i64)>, } -impl History { - pub(crate) 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, - } - } +pub(crate) trait HistoryExt: Sized { + fn serialize(&self) -> Result<DecryptedData>; + fn read_optional_string(bytes: &[u8]) -> Result<(Option<String>, &[u8])>; + fn deserialize_v0(bytes: &[u8]) -> Result<Self>; + fn deserialize_v1(bytes: &[u8]) -> Result<Self>; + fn deserialize(bytes: &[u8], version: &str) -> Result<Self>; + fn success(&self) -> bool; +} - pub(crate) fn serialize(&self) -> Result<DecryptedData> { +impl HistoryExt for History { + fn serialize(&self) -> Result<DecryptedData> { // This is pretty much the same as what we used for the old history, with one difference - // it uses integers for timestamps rather than a string format. @@ -358,7 +244,7 @@ impl History { }) } - pub(crate) fn deserialize(bytes: &[u8], version: &str) -> Result<Self> { + fn deserialize(bytes: &[u8], version: &str) -> Result<Self> { match version { HISTORY_VERSION_V0 => Self::deserialize_v0(bytes), HISTORY_VERSION_V1 => Self::deserialize_v1(bytes), @@ -367,118 +253,10 @@ impl History { } } - /// Builder for a history entry that is captured via hook. - /// - /// 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`. - /// - /// ## Examples - /// ```rust - /// use crate::aclient::history::History; - /// - /// let history: History = History::capture() - /// .timestamp(time::OffsetDateTime::now_utc()) - /// .command("ls -la") - /// .cwd("/home/user") - /// .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 `cwd` is missing - /// let history: History = History::capture() - /// .timestamp(time::OffsetDateTime::now_utc()) - /// .command("ls -la") - /// .build() - /// .into(); - /// ``` - pub fn capture() -> builder::HistoryCapturedBuilder { - builder::HistoryCaptured::builder() - } - - /// 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(crate) fn daemon() -> builder::HistoryDaemonCaptureBuilder { - builder::HistoryDaemonCapture::builder() - } - - /// Builder for a history entry that is imported from the database. - /// - /// All fields are required, as they are all present in the database. - /// - /// ```compile_fail - /// use crate::aclient::history::History; - /// - /// // this will not compile because `id` field is missing - /// let history: History = History::from_db() - /// .timestamp(time::OffsetDateTime::now_utc()) - /// .command("ls -la".to_string()) - /// .cwd("/home/user".to_string()) - /// .exit(0) - /// .duration(100) - /// .session("somesession".to_string()) - /// .hostname("localhost".to_string()) - /// .author("user".to_string()) - /// .intent(None) - /// .deleted_at(None) - /// .build() - /// .into(); - /// ``` - pub(crate) fn from_db() -> builder::HistoryFromDbBuilder { - builder::HistoryFromDb::builder() - } - - pub(crate) fn success(&self) -> bool { + #[expect(unused)] + fn success(&self) -> bool { self.exit == 0 || self.duration == -1 } - - 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)] @@ -490,89 +268,12 @@ pub struct SettingsFilter<'a> { #[cfg(test)] mod tests { - use regex::RegexSet; use time::macros::datetime; - use crate::aclient::{history::HISTORY_VERSION, settings::Settings}; + use crate::aclient::history::{HISTORY_VERSION, HistoryExt}; use super::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::capture() - .timestamp(time::OffsetDateTime::now_utc()) - .command("echo foo") - .cwd("/") - .build() - .into(); - - let with_space: History = History::capture() - .timestamp(time::OffsetDateTime::now_utc()) - .command(" echo bar") - .cwd("/") - .build() - .into(); - - let empty: History = History::capture() - .timestamp(time::OffsetDateTime::now_utc()) - .command("") - .cwd("/") - .build() - .into(); - - let stripe_key: History = History::capture() - .timestamp(time::OffsetDateTime::now_utc()) - .command("curl foo.com/bar?key=sk_test_1234567890abcdefghijklmnop") - .cwd("/") - .build() - .into(); - - let secret_dir: History = History::capture() - .timestamp(time::OffsetDateTime::now_utc()) - .command("echo ohno") - .cwd("/supasecret") - .build() - .into(); - - let with_psql: History = History::capture() - .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)); - } - #[test] fn test_serialize_deserialize() { let history = History { diff --git a/crates/daemon/src/aclient/history/store.rs b/crates/daemon/src/aclient/history/store.rs index 216f85d6..c62f9068 100644 --- a/crates/daemon/src/aclient/history/store.rs +++ b/crates/daemon/src/aclient/history/store.rs @@ -2,14 +2,16 @@ use std::collections::HashSet; use eyre::{Result, bail, eyre}; use rmp::decode::Bytes; +use turtle::history::{History, HistoryId}; use crate::aclient::{ database::ClientSqlite, + history::HistoryExt, record::{encryption::PASETO_V4, sqlite_store::SqliteStore}, }; use turtle_common::record::{DecryptedData, Host, HostId, Record, RecordId, RecordIdx}; -use super::{HISTORY_TAG, HISTORY_VERSION, HISTORY_VERSION_V0, History, HistoryId}; +use super::{HISTORY_TAG, HISTORY_VERSION, HISTORY_VERSION_V0}; #[derive(Debug, Clone)] pub(crate) struct HistoryStore { diff --git a/crates/daemon/src/aclient/mod.rs b/crates/daemon/src/aclient/mod.rs index 3f3709a9..f2d14e01 100644 --- a/crates/daemon/src/aclient/mod.rs +++ b/crates/daemon/src/aclient/mod.rs @@ -1,11 +1,10 @@ -pub mod database; +pub(crate) mod database; pub mod history; -pub mod record; -pub mod settings; +pub(crate) mod record; +pub(crate) mod settings; +pub(crate) mod api_client; pub(crate) mod encryption; pub(crate) mod meta; -pub(crate) mod utils; pub(crate) mod ordering; -pub(crate) mod secrets; -pub(crate) mod api_client; +pub(crate) mod utils; diff --git a/crates/daemon/src/aclient/ordering.rs b/crates/daemon/src/aclient/ordering.rs index 84001f52..8fa6498e 100644 --- a/crates/daemon/src/aclient/ordering.rs +++ b/crates/daemon/src/aclient/ordering.rs @@ -1,6 +1,7 @@ use minspan::minspan; +use turtle::history::History; -use super::{history::History, settings::SearchMode}; +use super::settings::SearchMode; pub(crate) fn reorder_fuzzy(mode: SearchMode, query: &str, res: Vec<History>) -> Vec<History> { match mode { diff --git a/crates/daemon/src/aclient/utils.rs b/crates/daemon/src/aclient/utils.rs index cf515183..18e732a0 100644 --- a/crates/daemon/src/aclient/utils.rs +++ b/crates/daemon/src/aclient/utils.rs @@ -1,18 +1,3 @@ -pub(crate) fn get_hostname() -> String { - std::env::var("ATUIN_HOST_NAME") - .unwrap_or_else(|_| whoami::hostname().unwrap_or_else(|_| "unknown-host".to_string())) -} - -pub(crate) fn get_username() -> String { - std::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. -pub(crate) fn get_host_user() -> String { - format!("{}:{}", get_hostname(), get_username()) -} - /// Setup a [`SQLite`] database. /// /// This takes care of correct locking, so that we avoid a race when setting up the database. diff --git a/crates/daemon/src/aclient/secrets.rs b/crates/turtle/src/history/secrets.rs index 08d24339..08d24339 100644 --- a/crates/daemon/src/aclient/secrets.rs +++ b/crates/turtle/src/history/secrets.rs |
