diff options
| author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-07-20 19:30:40 +0200 |
|---|---|---|
| committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-07-20 19:30:40 +0200 |
| commit | 966a80c4199a49898cc7d8641012d520ce6b2efa (patch) | |
| tree | 51029ff75842090fd1eecbea97b6f7c447e3dea9 /crates | |
| parent | chore(server): Remove warnings (diff) | |
| download | atuin-966a80c4199a49898cc7d8641012d520ce6b2efa.zip | |
chore: Commit
Diffstat (limited to '')
28 files changed, 626 insertions, 730 deletions
diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index fd5ea57a..058f2f3a 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -14,6 +14,7 @@ repository = { workspace = true } [dependencies] turtle-daemon = {workspace = true} turtle-common = {workspace = true} +turtle = { workspace = true } clap = { version = "4.5.7", features = ["derive"] } clap_complete = "4.5.8" clap_complete_nushell = "4.5.4" diff --git a/crates/client/src/command/client/history/start.rs b/crates/client/src/command/client/history/start.rs index 81aaa904..defd46a3 100644 --- a/crates/client/src/command/client/history/start.rs +++ b/crates/client/src/command/client/history/start.rs @@ -6,9 +6,10 @@ use crate::{ use eyre::{Result, eyre}; use time::OffsetDateTime; use tracing::debug; +use turtle::History; use turtle_common::utils; use turtle_daemon::{ - aclient::history::{History, SettingsFilter}, + aclient::history::SettingsFilter, api::client::{HistoryClient, daemon_matches_expected, daemon_mismatch_message}, }; @@ -23,7 +24,7 @@ pub(super) async fn handle( let cwd = utils::get_current_dir(); let command = normalize_command_for_storage(command, settings); - let mut h: History = History::capture() + let mut h: History = History::daemon() .timestamp(OffsetDateTime::now_utc()) .command(command) .cwd(cwd) diff --git a/crates/daemon/Cargo.toml b/crates/daemon/Cargo.toml index 07fd84d7..830dbd12 100644 --- a/crates/daemon/Cargo.toml +++ b/crates/daemon/Cargo.toml @@ -13,6 +13,7 @@ repository = { workspace = true } [dependencies] turtle-common = { workspace = true } +turtle = { workspace = true } async-trait = "0.1.58" axum = "0.8" base64 = "0.22" 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/api/server/control.rs b/crates/daemon/src/api/control.rs index a5e26355..a9d9cff3 100644 --- a/crates/daemon/src/api/server/control.rs +++ b/crates/daemon/src/api/control.rs @@ -6,15 +6,17 @@ use tokio::time::{self, MissedTickBehavior}; use tonic::{Request, Response, Status}; use tracing::{Level, instrument}; +use turtle::generated::{ + DAEMON_PROTOCOL_VERSION, + control::{ + ForceSyncReply, ForceSyncRequest, PathsReply, PathsRequest, StatusReply, StatusRequest, + control_server::{Control, ControlServer}, + }, +}; + use crate::{ + DAEMON_VERSION, aclient::{history::store::HistoryStore, record::sync, settings::Settings}, - api::{ - DAEMON_PROTOCOL_VERSION, DAEMON_VERSION, - generated::control::{ - ForceSyncReply, ForceSyncRequest, PathsReply, PathsRequest, StatusReply, StatusRequest, - control_server::{Control, ControlServer}, - }, - }, daemon::DaemonHandle, events::DaemonEvent, }; diff --git a/crates/daemon/src/api/server/history.rs b/crates/daemon/src/api/history.rs index 0edf3b94..bcd2ee5a 100644 --- a/crates/daemon/src/api/server/history.rs +++ b/crates/daemon/src/api/history.rs @@ -10,20 +10,23 @@ use tracing::{Level, instrument}; use crate::{ aclient::{ database::{ClientSqlite, current_context}, - history::{History, HistoryId, store::HistoryStore}, + history::store::HistoryStore, settings::Settings, }, - api::{ + daemon::DaemonHandle, + events::DaemonEvent, +}; +use turtle::{ + generated::{ DAEMON_PROTOCOL_VERSION, - generated::history::{ + history::{ EndHistoryReply, EndHistoryRequest, HistoryEntry, HistoryEventKind, HistoryReply, HistoryRequest, StartHistoryReply, StartHistoryRequest, TailHistoryReply, TailHistoryRequest, history_server::{History as HistorySvc, HistoryServer}, }, }, - daemon::DaemonHandle, - events::DaemonEvent, + history::{History, HistoryId}, }; /// The gRPC service implementation. diff --git a/crates/daemon/src/api/mod.rs b/crates/daemon/src/api/mod.rs index b7f82a7b..8d475fe9 100644 --- a/crates/daemon/src/api/mod.rs +++ b/crates/daemon/src/api/mod.rs @@ -1,6 +1,2 @@ -pub mod client; -pub(crate) mod generated; -pub(crate) mod server; - -pub(crate) const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION"); -const DAEMON_PROTOCOL_VERSION: u32 = 1; +pub(crate) mod control; +pub(crate) mod history; diff --git a/crates/daemon/src/api/server/mod.rs b/crates/daemon/src/api/server/mod.rs deleted file mode 100644 index 8d475fe9..00000000 --- a/crates/daemon/src/api/server/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub(crate) mod control; -pub(crate) mod history; diff --git a/crates/daemon/src/events.rs b/crates/daemon/src/events.rs index e3f6ebc9..1864d224 100644 --- a/crates/daemon/src/events.rs +++ b/crates/daemon/src/events.rs @@ -7,7 +7,7 @@ //! External processes (like CLI commands) can also inject events via the //! Control gRPC service. -use crate::aclient::history::{History, HistoryId}; +use turtle::history::{History, HistoryId}; use turtle_common::record::RecordId; /// Events that flow through the daemon's event bus. diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs deleted file mode 100644 index c6877096..00000000 --- a/crates/daemon/src/lib.rs +++ /dev/null @@ -1,161 +0,0 @@ -#![expect(unused_crate_dependencies, reason = "Didn't remove them yet")] - -use std::{ - fs::{self, File, OpenOptions}, - io::Write, - path::{Path, PathBuf}, - time::{Duration, Instant}, -}; - -use eyre::{Context, Result, bail, eyre}; -use fs4::fs_std::FileExt; -use tokio::time::sleep; - -use crate::{ - aclient::{database::ClientSqlite, record::sqlite_store::SqliteStore, settings::Settings}, - api::{ - DAEMON_VERSION, - server::{control::ControlService, history::HistoryService}, - }, - daemon::Daemon, -}; - -pub mod aclient; - -pub mod api; -pub(crate) mod daemon; -pub(crate) mod events; -pub(crate) mod server; - -/// Boot the daemon. -/// -/// This creates a daemon, -/// starts the gRPC server with services, and runs the event loop. -pub async fn boot(settings: Settings, store: SqliteStore, history_db: ClientSqlite) -> Result<()> { - let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path); - let _pidfile_guard = PidfileGuard::acquire(&pidfile_path)?; - - let mut daemon = Daemon::builder(settings.clone()) - .store(store) - .history_db(history_db.clone()) - .build()?; - - let handle = { - let handle = daemon.handle(); - - // Spawn signal handler to emit ShutdownRequested on Ctrl+C/SIGTERM - let signal_handle = handle.clone(); - tokio::spawn(async move { - shutdown_signal().await; - tracing::info!("received shutdown signal"); - signal_handle.shutdown(); - }); - - handle - }; - - let history_service = HistoryService::new(handle.clone(), history_db).await?; - let control_service = ControlService::new(handle.clone()); - - server::run_grpc_server( - &settings, - history_service.into_server(), - control_service.into_server(), - handle, - )?; - - daemon.run_event_loop().await?; - - tracing::info!("daemon shut down complete"); - Ok(()) -} - -/// Wait for a shutdown signal (Ctrl+C or SIGTERM). -#[cfg(unix)] -async fn shutdown_signal() { - let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - .expect("failed to register sigterm handler"); - let mut int = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) - .expect("failed to register sigint handler"); - - tokio::select! { - _ = term.recv() => {}, - _ = int.recv() => {}, - } -} - -struct PidfileGuard { - file: File, -} - -impl PidfileGuard { - fn acquire(path: &Path) -> Result<Self> { - let mut file = open_lock_file(path)?; - - if !file.try_lock_exclusive()? { - bail!( - "daemon already running (pidfile lock busy at {})", - path.display() - ); - } - - file.set_len(0) - .wrap_err_with(|| format!("could not truncate daemon pidfile {}", path.display()))?; - writeln!(file, "{}", std::process::id()) - .and_then(|()| writeln!(file, "{DAEMON_VERSION}")) - .wrap_err_with(|| format!("could not write daemon pidfile {}", path.display()))?; - - Ok(Self { file }) - } -} - -impl Drop for PidfileGuard { - fn drop(&mut self) { - drop(self.file.unlock()); - } -} - -fn open_lock_file(path: &Path) -> Result<File> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .wrap_err_with(|| format!("could not create lock directory {}", parent.display()))?; - } - - OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(path) - .wrap_err_with(|| format!("could not open lock file {}", path.display())) -} - -async fn wait_for_lock(path: &Path, timeout: Duration) -> Result<File> { - const LOCK_POLL: Duration = Duration::from_millis(20); - - let file = open_lock_file(path)?; - let start = Instant::now(); - - loop { - match file.try_lock_exclusive() { - Ok(true) => return Ok(file), - Ok(false) => { - if start.elapsed() >= timeout { - bail!("timed out waiting for lock at {}", path.display()); - } - - sleep(LOCK_POLL).await; - } - Err(err) => { - return Err(eyre!("could not lock {}: {err}", path.display())); - } - } - } -} - -async fn wait_for_pidfile_available(path: &Path, timeout: Duration) -> Result<()> { - let file = wait_for_lock(path, timeout).await?; - file.unlock() - .wrap_err_with(|| format!("failed to unlock {}", path.display()))?; - Ok(()) -} diff --git a/crates/daemon/src/main.rs b/crates/daemon/src/main.rs index 46d9ad4d..59d4c7ff 100644 --- a/crates/daemon/src/main.rs +++ b/crates/daemon/src/main.rs @@ -1,15 +1,35 @@ -#![expect(unused_crate_dependencies, reason = "Didn't remove them yet")] +#![expect(unused_crate_dependencies)] + +use std::{ + fs::{self, File, OpenOptions}, + io::Write, + path::{Path, PathBuf}, + time::{Duration, Instant}, +}; use clap::Parser; -use eyre::{Result, WrapErr}; -use std::path::PathBuf; -use turtle_daemon::aclient::{ - database::ClientSqlite, record::sqlite_store::SqliteStore, settings::Settings, +use eyre::WrapErr; +use eyre::{Context, Result, bail, eyre}; +use fs4::fs_std::FileExt; +use tokio::time::sleep; + +use crate::{ + aclient::{database::ClientSqlite, record::sqlite_store::SqliteStore, settings::Settings}, + api::{control::ControlService, history::HistoryService}, + daemon::Daemon, }; +pub(crate) mod aclient; +pub(crate) mod api; +pub(crate) mod daemon; +pub(crate) mod events; +pub(crate) mod server; + +const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION"); + #[derive(Parser, Debug)] #[command(infer_subcommands = true)] -pub(crate) enum Cmd { +enum Cmd { /// Start the daemon server Start { /// Also write daemon logs to the console (useful for debugging) @@ -28,8 +48,139 @@ async fn main() -> Result<()> { let sqlite_store = SqliteStore::new(record_store_path, settings.local_timeout).await?; match Cmd::parse() { - Cmd::Start { show_logs, .. } => { - turtle_daemon::boot(settings, sqlite_store, history_db).await + Cmd::Start { show_logs, .. } => boot(settings, sqlite_store, history_db).await, + } +} + +/// Boot the daemon. +/// +/// This creates a daemon, +/// starts the gRPC server with services, and runs the event loop. +async fn boot(settings: Settings, store: SqliteStore, history_db: ClientSqlite) -> Result<()> { + let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path); + let _pidfile_guard = PidfileGuard::acquire(&pidfile_path)?; + + let mut daemon = Daemon::builder(settings.clone()) + .store(store) + .history_db(history_db.clone()) + .build()?; + + let handle = { + let handle = daemon.handle(); + + // Spawn signal handler to emit ShutdownRequested on Ctrl+C/SIGTERM + let signal_handle = handle.clone(); + tokio::spawn(async move { + shutdown_signal().await; + tracing::info!("received shutdown signal"); + signal_handle.shutdown(); + }); + + handle + }; + + let history_service = HistoryService::new(handle.clone(), history_db).await?; + let control_service = ControlService::new(handle.clone()); + + server::run_grpc_server( + &settings, + history_service.into_server(), + control_service.into_server(), + handle, + )?; + + daemon.run_event_loop().await?; + + tracing::info!("daemon shut down complete"); + Ok(()) +} + +/// Wait for a shutdown signal (Ctrl+C or SIGTERM). +#[cfg(unix)] +async fn shutdown_signal() { + let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to register sigterm handler"); + let mut int = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) + .expect("failed to register sigint handler"); + + tokio::select! { + _ = term.recv() => {}, + _ = int.recv() => {}, + } +} + +struct PidfileGuard { + file: File, +} + +impl PidfileGuard { + fn acquire(path: &Path) -> Result<Self> { + let mut file = open_lock_file(path)?; + + if !file.try_lock_exclusive()? { + bail!( + "daemon already running (pidfile lock busy at {})", + path.display() + ); } + + file.set_len(0) + .wrap_err_with(|| format!("could not truncate daemon pidfile {}", path.display()))?; + writeln!(file, "{}", std::process::id()) + .and_then(|()| writeln!(file, "{DAEMON_VERSION}")) + .wrap_err_with(|| format!("could not write daemon pidfile {}", path.display()))?; + + Ok(Self { file }) + } +} + +impl Drop for PidfileGuard { + fn drop(&mut self) { + drop(self.file.unlock()); + } +} + +fn open_lock_file(path: &Path) -> Result<File> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .wrap_err_with(|| format!("could not create lock directory {}", parent.display()))?; } + + OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path) + .wrap_err_with(|| format!("could not open lock file {}", path.display())) +} + +async fn wait_for_lock(path: &Path, timeout: Duration) -> Result<File> { + const LOCK_POLL: Duration = Duration::from_millis(20); + + let file = open_lock_file(path)?; + let start = Instant::now(); + + loop { + match file.try_lock_exclusive() { + Ok(true) => return Ok(file), + Ok(false) => { + if start.elapsed() >= timeout { + bail!("timed out waiting for lock at {}", path.display()); + } + + sleep(LOCK_POLL).await; + } + Err(err) => { + return Err(eyre!("could not lock {}: {err}", path.display())); + } + } + } +} + +async fn wait_for_pidfile_available(path: &Path, timeout: Duration) -> Result<()> { + let file = wait_for_lock(path, timeout).await?; + file.unlock() + .wrap_err_with(|| format!("failed to unlock {}", path.display()))?; + Ok(()) } diff --git a/crates/daemon/src/server.rs b/crates/daemon/src/server.rs index 3400ad62..6747f276 100644 --- a/crates/daemon/src/server.rs +++ b/crates/daemon/src/server.rs @@ -1,19 +1,14 @@ -use std::os::unix::net::SocketAddr; -use std::path::PathBuf; +use std::{os::unix::net::SocketAddr, path::PathBuf}; use eyre::Result; use eyre::{OptionExt, WrapErr}; - -#[cfg(unix)] -use crate::api::server::{control::ControlService, history::HistoryService}; -use crate::{ - aclient::settings::Settings, - api::generated::{ - control::control_server::ControlServer, history::history_server::HistoryServer, - }, - daemon::DaemonHandle, +use turtle::generated::{ + control::control_server::ControlServer, history::history_server::HistoryServer, }; +use crate::api::{control::ControlService, history::HistoryService}; +use crate::{aclient::settings::Settings, daemon::DaemonHandle}; + /// Run the gRPC server with the given services. /// /// This starts the gRPC server in the background and returns immediately. diff --git a/crates/turtle/Cargo.toml b/crates/turtle/Cargo.toml index 3595008d..102c5e9a 100644 --- a/crates/turtle/Cargo.toml +++ b/crates/turtle/Cargo.toml @@ -12,6 +12,7 @@ homepage = { workspace = true } repository = { workspace = true } [dependencies] +turtle-common = {workspace = true} async-trait = "0.1.58" axum = "0.8" base64 = "0.22" diff --git a/crates/daemon/build.rs b/crates/turtle/build.rs index 62612968..62612968 100644 --- a/crates/daemon/build.rs +++ b/crates/turtle/build.rs diff --git a/crates/daemon/proto/control.proto b/crates/turtle/proto/control.proto index a8026cb8..a8026cb8 100644 --- a/crates/daemon/proto/control.proto +++ b/crates/turtle/proto/control.proto diff --git a/crates/daemon/proto/history.proto b/crates/turtle/proto/history.proto index 850b16b9..850b16b9 100644 --- a/crates/daemon/proto/history.proto +++ b/crates/turtle/proto/history.proto diff --git a/crates/daemon/src/api/client/mod.rs b/crates/turtle/src/client/mod.rs index c588fb09..07f01e6c 100644 --- a/crates/daemon/src/api/client/mod.rs +++ b/crates/turtle/src/client/mod.rs @@ -9,26 +9,21 @@ use hyper_util::rt::TokioIo; #[cfg(unix)] use tokio::net::UnixStream; -use crate::api::generated; -use crate::api::generated::control::{ForceSyncReply, ForceSyncRequest, PathsReply, PathsRequest}; -use crate::api::generated::history::{HistoryEntry, HistoryRequest}; -use crate::{ - aclient::history::History, - api::{ - DAEMON_PROTOCOL_VERSION, DAEMON_VERSION, - generated::{ - control::{ - StatusReply, StatusRequest, control_client::ControlClient as ControlServiceClient, - }, - history::{ - EndHistoryReply, EndHistoryRequest, StartHistoryReply, StartHistoryRequest, - TailHistoryRequest, history_client::HistoryClient as HistoryServiceClient, - }, - }, +use crate::generated::{ + self, DAEMON_PROTOCOL_VERSION, + control::{ + ForceSyncReply, ForceSyncRequest, PathsReply, PathsRequest, StatusReply, StatusRequest, + control_client::ControlClient as ControlServiceClient, + }, + history::{ + EndHistoryReply, EndHistoryRequest, HistoryEntry, HistoryRequest, StartHistoryReply, + StartHistoryRequest, TailHistoryRequest, + history_client::HistoryClient as HistoryServiceClient, }, }; -pub use crate::api::generated::history::{HistoryEventKind, TailHistoryReply}; +pub use crate::generated::history::{HistoryEventKind, TailHistoryReply}; +use crate::history::History; fn normalize_optional_field(value: &str) -> Option<String> { let trimmed = value.trim(); @@ -60,13 +55,13 @@ pub fn history_entry_to_history(entry: HistoryEntry) -> History { #[must_use] pub fn daemon_matches_expected(version: &str, protocol: u32) -> bool { - version == DAEMON_VERSION && protocol == DAEMON_PROTOCOL_VERSION + protocol == DAEMON_PROTOCOL_VERSION } #[must_use] pub fn daemon_mismatch_message(version: &str, protocol: u32) -> String { if protocol == DAEMON_PROTOCOL_VERSION { - format!("daemon is out of date: expected {DAEMON_VERSION}, got {version}") + unreachable!() } else { format!("daemon protocol mismatch: expected {DAEMON_PROTOCOL_VERSION}, got {protocol}") } diff --git a/crates/daemon/src/api/generated.rs b/crates/turtle/src/generated.rs index 304edcd9..e5e28ac7 100644 --- a/crates/daemon/src/api/generated.rs +++ b/crates/turtle/src/generated.rs @@ -1,28 +1,23 @@ #![expect( - unreachable_pub, unused_qualifications, clippy::doc_markdown, clippy::default_trait_access, clippy::too_many_lines, - clippy::trivially_copy_pass_by_ref, clippy::allow_attributes, clippy::derive_partial_eq_without_eq, reason = "All of these lints are triggered by the generated code" )] -/// Semantic command capture gRPC service types. -pub(crate) mod semantic { - tonic::include_proto!("semantic"); -} +pub const DAEMON_PROTOCOL_VERSION: u32 = 1; /// History module for the daemon gRPC history service. /// /// This module contains the proto-generated types for the history gRPC service. -pub(crate) mod history { +pub mod history { tonic::include_proto!("history"); } /// Control module for external control. -pub(crate) mod control { +pub mod control { tonic::include_proto!("control"); } diff --git a/crates/turtle/src/history/builder.rs b/crates/turtle/src/history/builder.rs new file mode 100644 index 00000000..7eca0491 --- /dev/null +++ b/crates/turtle/src/history/builder.rs @@ -0,0 +1,78 @@ +use typed_builder::TypedBuilder; + +use super::History; + +/// 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 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 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/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)); + // } +} 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 diff --git a/crates/turtle/src/lib.rs b/crates/turtle/src/lib.rs index e69de29b..c78b0475 100644 --- a/crates/turtle/src/lib.rs +++ b/crates/turtle/src/lib.rs @@ -0,0 +1,5 @@ +#![expect(unused_crate_dependencies)] + +pub mod client; +pub mod generated; +pub mod history; |
