aboutsummaryrefslogtreecommitdiffstats
path: root/crates/turtle/src/history/mod.rs
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-07-20 19:30:40 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-07-20 19:30:40 +0200
commit966a80c4199a49898cc7d8641012d520ce6b2efa (patch)
tree51029ff75842090fd1eecbea97b6f7c447e3dea9 /crates/turtle/src/history/mod.rs
parentchore(server): Remove warnings (diff)
downloadatuin-966a80c4199a49898cc7d8641012d520ce6b2efa.zip
chore: Commit
Diffstat (limited to 'crates/turtle/src/history/mod.rs')
-rw-r--r--crates/turtle/src/history/mod.rs300
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));
+ // }
+}