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/turtle | |
| parent | chore(server): Remove warnings (diff) | |
| download | atuin-966a80c4199a49898cc7d8641012d520ce6b2efa.zip | |
chore: Commit
Diffstat (limited to '')
| -rw-r--r-- | crates/turtle/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/turtle/build.rs (renamed from crates/daemon/build.rs) | 0 | ||||
| -rw-r--r-- | crates/turtle/proto/control.proto (renamed from crates/daemon/proto/control.proto) | 0 | ||||
| -rw-r--r-- | crates/turtle/proto/history.proto (renamed from crates/daemon/proto/history.proto) | 0 | ||||
| -rw-r--r-- | crates/turtle/src/client/mod.rs (renamed from crates/daemon/src/api/client/mod.rs) | 33 | ||||
| -rw-r--r-- | crates/turtle/src/generated.rs (renamed from crates/daemon/src/api/generated.rs) | 11 | ||||
| -rw-r--r-- | crates/turtle/src/history/builder.rs | 78 | ||||
| -rw-r--r-- | crates/turtle/src/history/mod.rs | 300 | ||||
| -rw-r--r-- | crates/turtle/src/history/secrets.rs (renamed from crates/daemon/src/aclient/secrets.rs) | 0 | ||||
| -rw-r--r-- | crates/turtle/src/lib.rs | 5 |
10 files changed, 401 insertions, 27 deletions
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; |
