aboutsummaryrefslogtreecommitdiffstats
path: root/crates/turtle
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
parentchore(server): Remove warnings (diff)
downloadatuin-966a80c4199a49898cc7d8641012d520ce6b2efa.zip
chore: Commit
Diffstat (limited to 'crates/turtle')
-rw-r--r--crates/turtle/Cargo.toml1
-rw-r--r--crates/turtle/build.rs32
-rw-r--r--crates/turtle/proto/control.proto33
-rw-r--r--crates/turtle/proto/history.proto78
-rw-r--r--crates/turtle/src/client/mod.rs267
-rw-r--r--crates/turtle/src/generated.rs23
-rw-r--r--crates/turtle/src/history/builder.rs78
-rw-r--r--crates/turtle/src/history/mod.rs300
-rw-r--r--crates/turtle/src/history/secrets.rs223
-rw-r--r--crates/turtle/src/lib.rs5
10 files changed, 1040 insertions, 0 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/turtle/build.rs b/crates/turtle/build.rs
new file mode 100644
index 00000000..62612968
--- /dev/null
+++ b/crates/turtle/build.rs
@@ -0,0 +1,32 @@
+use std::{env, fs, path::PathBuf};
+
+use protox::Compiler;
+use protox::prost::Message;
+
+fn main() -> Result<(), std::io::Error> {
+ let proto_paths = [
+ "proto/history.proto",
+ "proto/control.proto",
+ ];
+ let proto_include_dirs = ["proto"];
+
+ let file_descriptor_set = Compiler::new(proto_include_dirs)
+ .map_err(std::io::Error::other)?
+ .include_source_info(true)
+ .include_imports(true)
+ .open_files(proto_paths)
+ .map_err(std::io::Error::other)?
+ .file_descriptor_set();
+
+ let file_descriptor_path = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR not set"))
+ .join("file_descriptor_set.bin");
+ fs::write(&file_descriptor_path, file_descriptor_set.encode_to_vec()).unwrap();
+
+ tonic_prost_build::configure()
+ .build_server(true)
+ .file_descriptor_set_path(&file_descriptor_path)
+ .skip_protoc_run()
+ .compile_protos(&proto_paths, &proto_include_dirs)?;
+
+ Ok(())
+}
diff --git a/crates/turtle/proto/control.proto b/crates/turtle/proto/control.proto
new file mode 100644
index 00000000..a8026cb8
--- /dev/null
+++ b/crates/turtle/proto/control.proto
@@ -0,0 +1,33 @@
+syntax = "proto3";
+package control;
+
+service Control {
+ // Tell the daemon to perform a sync operation.
+ rpc ForceSync(ForceSyncRequest) returns (ForceSyncReply);
+
+ // Query the daemon for it's status.
+ rpc Status(StatusRequest) returns (StatusReply);
+
+ // Query the daemon about used paths.
+ rpc Paths(PathsRequest) returns (PathsReply);
+}
+
+message ForceSyncRequest {}
+message ForceSyncReply {
+ bool accepted = 1;
+}
+
+message StatusRequest {}
+message StatusReply {
+ bool healthy = 1;
+ string version = 2;
+ uint32 pid = 3;
+ uint32 protocol = 4;
+}
+
+message PathsRequest {}
+message PathsReply {
+ string config = 1;
+ string db = 2;
+ string socket = 3;
+}
diff --git a/crates/turtle/proto/history.proto b/crates/turtle/proto/history.proto
new file mode 100644
index 00000000..850b16b9
--- /dev/null
+++ b/crates/turtle/proto/history.proto
@@ -0,0 +1,78 @@
+syntax = "proto3";
+package history;
+
+service History {
+ rpc StartHistory(StartHistoryRequest) returns (StartHistoryReply);
+ rpc EndHistory(EndHistoryRequest) returns (EndHistoryReply);
+
+ rpc TailHistory(TailHistoryRequest) returns (stream TailHistoryReply);
+
+ // Request history from the daemon
+ rpc History(HistoryRequest) returns (HistoryReply);
+}
+
+message StartHistoryRequest {
+ uint64 timestamp = 1; // nanosecond unix epoch
+ string command = 2;
+ string cwd = 3;
+ string session = 4;
+ string hostname = 5;
+ string author = 6;
+ string intent = 7;
+}
+message StartHistoryReply {
+ string id = 1;
+ string version = 2;
+ uint32 protocol = 3;
+}
+
+message EndHistoryRequest {
+ string id = 1;
+ int64 exit = 2;
+ uint64 duration = 3;
+}
+message EndHistoryReply {
+ string id = 1;
+ uint64 idx = 2;
+ string version = 3;
+ uint32 protocol = 4;
+}
+
+
+message TailHistoryRequest {}
+message TailHistoryReply {
+ HistoryEventKind kind = 1;
+ HistoryEntry history = 2;
+}
+
+enum HistoryEventKind {
+ HISTORY_EVENT_KIND_UNSPECIFIED = 0;
+ HISTORY_EVENT_KIND_STARTED = 1;
+ HISTORY_EVENT_KIND_ENDED = 2;
+}
+
+message HistoryEntry {
+ uint64 timestamp = 1; // nanosecond unix epoch
+ string id = 2;
+ string command = 3;
+ string cwd = 4;
+ string session = 5;
+ string hostname = 6;
+ string author = 7;
+ string intent = 8;
+ int64 exit = 9;
+ int64 duration = 10;
+}
+
+message Range {
+ uint64 start = 1;
+ uint64 end = 2;
+}
+
+message HistoryRequest {
+ string session = 1;
+ optional Range range = 2;
+}
+message HistoryReply {
+ repeated HistoryEntry entries = 1;
+}
diff --git a/crates/turtle/src/client/mod.rs b/crates/turtle/src/client/mod.rs
new file mode 100644
index 00000000..07f01e6c
--- /dev/null
+++ b/crates/turtle/src/client/mod.rs
@@ -0,0 +1,267 @@
+use eyre::{Context as EyreContext, Result};
+use time::OffsetDateTime;
+use tonic::Code;
+use tonic::transport::{Channel, Endpoint, Uri};
+use tower::service_fn;
+
+use hyper_util::rt::TokioIo;
+
+#[cfg(unix)]
+use tokio::net::UnixStream;
+
+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::generated::history::{HistoryEventKind, TailHistoryReply};
+use crate::history::History;
+
+fn normalize_optional_field(value: &str) -> Option<String> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ None
+ } else {
+ Some(trimmed.to_owned())
+ }
+}
+
+pub fn history_entry_to_history(entry: HistoryEntry) -> History {
+ let timestamp = OffsetDateTime::from_unix_timestamp_nanos(i128::from(entry.timestamp))
+ .expect("Daemon history timestamp should always be valid");
+
+ History {
+ id: entry.id.into(),
+ timestamp,
+ duration: entry.duration,
+ exit: entry.exit,
+ command: entry.command,
+ cwd: entry.cwd,
+ session: entry.session,
+ hostname: entry.hostname,
+ author: entry.author,
+ intent: normalize_optional_field(&entry.intent),
+ deleted_at: None,
+ }
+}
+
+#[must_use]
+pub fn daemon_matches_expected(version: &str, protocol: u32) -> bool {
+ protocol == DAEMON_PROTOCOL_VERSION
+}
+
+#[must_use]
+pub fn daemon_mismatch_message(version: &str, protocol: u32) -> String {
+ if protocol == DAEMON_PROTOCOL_VERSION {
+ unreachable!()
+ } else {
+ format!("daemon protocol mismatch: expected {DAEMON_PROTOCOL_VERSION}, got {protocol}")
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum DaemonClientErrorKind {
+ Connect,
+ Unavailable,
+ Unimplemented,
+ Other,
+}
+
+#[must_use]
+pub fn classify_error(error: &eyre::Report) -> DaemonClientErrorKind {
+ for cause in error.chain() {
+ if cause.downcast_ref::<tonic::transport::Error>().is_some() {
+ return DaemonClientErrorKind::Connect;
+ }
+
+ if let Some(status) = cause.downcast_ref::<tonic::Status>() {
+ return match status.code() {
+ Code::Unavailable => DaemonClientErrorKind::Unavailable,
+ Code::Unimplemented => DaemonClientErrorKind::Unimplemented,
+ _ => DaemonClientErrorKind::Other,
+ };
+ }
+ }
+
+ DaemonClientErrorKind::Other
+}
+
+#[derive(Debug)]
+pub enum Probe {
+ Ready(ControlClient),
+ NeedsRestart(String),
+ Unreachable(eyre::Report),
+}
+
+/// Check if a client can reach the daemon.
+pub async fn probe(path: String) -> Probe {
+ let mut client = match ControlClient::new(path).await {
+ Ok(client) => client,
+ Err(err) => return Probe::Unreachable(err),
+ };
+
+ match client.status().await {
+ Ok(status) => {
+ if daemon_matches_expected(&status.version, status.protocol) {
+ Probe::Ready(client)
+ } else {
+ Probe::NeedsRestart(daemon_mismatch_message(&status.version, status.protocol))
+ }
+ }
+ Err(err) => Probe::Unreachable(err),
+ }
+}
+
+// ============================================================================
+// History Client
+// ============================================================================
+
+#[derive(Debug)]
+pub struct HistoryClient {
+ client: HistoryServiceClient<Channel>,
+}
+
+pub struct Range {
+ pub start: OffsetDateTime,
+ pub end: OffsetDateTime,
+}
+
+// Wrap the grpc client
+impl HistoryClient {
+ #[cfg(unix)]
+ pub async fn new(path: String) -> Result<Self> {
+ use eyre::Context;
+
+ let log_path = path.clone();
+ let channel = Endpoint::try_from("http://atuin_local_daemon:0")?
+ .connect_with_connector(service_fn(move |_: Uri| {
+ let path = path.clone();
+
+ async move {
+ Ok::<_, std::io::Error>(TokioIo::new(UnixStream::connect(path.clone()).await?))
+ }
+ }))
+ .await
+ .wrap_err_with(|| {
+ format!(
+ "failed to connect to local atuin daemon at {}. Is it running?",
+ &log_path
+ )
+ })?;
+
+ let client = HistoryServiceClient::new(channel);
+
+ Ok(Self { client })
+ }
+
+ pub async fn start_history(&mut self, h: History) -> Result<StartHistoryReply> {
+ let req = StartHistoryRequest {
+ command: h.command,
+ cwd: h.cwd,
+ hostname: h.hostname,
+ session: h.session,
+ timestamp: h.timestamp.unix_timestamp_nanos() as u64,
+ author: h.author,
+ intent: h.intent.unwrap_or_default(),
+ };
+
+ Ok(self.client.start_history(req).await?.into_inner())
+ }
+
+ pub async fn history(&mut self, session: String, range: Option<Range>) -> Result<Vec<History>> {
+ let req = HistoryRequest {
+ session,
+ range: range.map(|r| generated::history::Range {
+ start: r.start.unix_timestamp() as u64,
+ end: r.end.unix_timestamp() as u64,
+ }),
+ };
+
+ let reply = self.client.history(req).await?.into_inner();
+
+ Ok(reply
+ .entries
+ .into_iter()
+ .map(history_entry_to_history)
+ .collect())
+ }
+
+ pub async fn end_history(
+ &mut self,
+ id: String,
+ duration: u64,
+ exit: i64,
+ ) -> Result<EndHistoryReply> {
+ let req = EndHistoryRequest { id, exit, duration };
+
+ Ok(self.client.end_history(req).await?.into_inner())
+ }
+
+ pub async fn tail_history(&mut self) -> Result<tonic::Streaming<TailHistoryReply>> {
+ Ok(self
+ .client
+ .tail_history(TailHistoryRequest {})
+ .await?
+ .into_inner())
+ }
+}
+
+// ============================================================================
+// Control Client
+// ============================================================================
+
+/// Client for the Control gRPC service.
+#[derive(Debug)]
+pub struct ControlClient {
+ client: ControlServiceClient<Channel>,
+}
+
+impl ControlClient {
+ /// Connect to the daemon's control service.
+ pub async fn new(path: String) -> Result<Self> {
+ let log_path = path.clone();
+ let channel = Endpoint::try_from("http://atuin_local_daemon:0")?
+ .connect_with_connector(service_fn(move |_: Uri| {
+ let path = path.clone();
+
+ async move {
+ Ok::<_, std::io::Error>(TokioIo::new(UnixStream::connect(path.clone()).await?))
+ }
+ }))
+ .await
+ .wrap_err_with(|| {
+ format!(
+ "failed to connect to local atuin daemon at {}. Is it running?",
+ &log_path
+ )
+ })?;
+
+ let client = ControlServiceClient::new(channel);
+
+ Ok(Self { client })
+ }
+
+ pub async fn paths(&mut self) -> Result<PathsReply> {
+ Ok(self.client.paths(PathsRequest {}).await?.into_inner())
+ }
+
+ pub async fn force_sync(&mut self) -> Result<ForceSyncReply> {
+ Ok(self
+ .client
+ .force_sync(ForceSyncRequest {})
+ .await?
+ .into_inner())
+ }
+
+ pub async fn status(&mut self) -> Result<StatusReply> {
+ Ok(self.client.status(StatusRequest {}).await?.into_inner())
+ }
+}
diff --git a/crates/turtle/src/generated.rs b/crates/turtle/src/generated.rs
new file mode 100644
index 00000000..e5e28ac7
--- /dev/null
+++ b/crates/turtle/src/generated.rs
@@ -0,0 +1,23 @@
+#![expect(
+ unused_qualifications,
+ clippy::doc_markdown,
+ clippy::default_trait_access,
+ clippy::too_many_lines,
+ clippy::allow_attributes,
+ clippy::derive_partial_eq_without_eq,
+ reason = "All of these lints are triggered by the generated code"
+)]
+
+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 mod history {
+ tonic::include_proto!("history");
+}
+
+/// Control module for external 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/turtle/src/history/secrets.rs b/crates/turtle/src/history/secrets.rs
new file mode 100644
index 00000000..08d24339
--- /dev/null
+++ b/crates/turtle/src/history/secrets.rs
@@ -0,0 +1,223 @@
+// This file will probably trigger a lot of scanners. Sorry.
+
+use regex::RegexSet;
+use std::sync::LazyLock;
+
+#[cfg(test)]
+pub(crate) enum TestValue<'a> {
+ Single(&'a str),
+ Multiple(&'a [&'a str]),
+}
+
+#[cfg(test)]
+type SpType<'a> = &'a [(&'a str, &'a str, TestValue<'a>)];
+
+#[cfg(not(test))]
+type SpType<'a> = &'a [(&'a str, &'a str)];
+
+/// A list of `(name, regex, test)`, where `test` should match against `regex`.
+pub(crate) static SECRET_PATTERNS: SpType<'_> = &[
+ (
+ "AWS Access Key ID",
+ "A[KS]IA[0-9A-Z]{16}",
+ #[cfg(test)]
+ TestValue::Single("AKIAIOSFODNN7EXAMPLE"),
+ ),
+ (
+ "AWS Secret Access Key env var",
+ "AWS_SECRET_ACCESS_KEY",
+ #[cfg(test)]
+ TestValue::Single("AWS_SECRET_ACCESS_KEY=KEYDATA"),
+ ),
+ (
+ "AWS Session Token env var",
+ "AWS_SESSION_TOKEN",
+ #[cfg(test)]
+ TestValue::Single("AWS_SESSION_TOKEN=KEYDATA"),
+ ),
+ (
+ "Microsoft Azure secret access key env var",
+ "AZURE_.*_KEY",
+ #[cfg(test)]
+ TestValue::Single("export AZURE_STORAGE_ACCOUNT_KEY=KEYDATA"),
+ ),
+ (
+ "Google cloud platform key env var",
+ "GOOGLE_SERVICE_ACCOUNT_KEY",
+ #[cfg(test)]
+ TestValue::Single("export GOOGLE_SERVICE_ACCOUNT_KEY=KEYDATA"),
+ ),
+ (
+ "Atuin login",
+ r"atuin\s+login",
+ #[cfg(test)]
+ TestValue::Single(
+ "atuin login -u mycoolusername -p mycoolpassword -k \"lots of random words\"",
+ ),
+ ),
+ (
+ "GitHub PAT (old)",
+ "ghp_[a-zA-Z0-9]{36}",
+ #[cfg(test)]
+ TestValue::Single("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"), // legit, I expired it
+ ),
+ (
+ "GitHub PAT (new)",
+ "gh1_[A-Za-z0-9]{21}_[A-Za-z0-9]{59}|github_pat_[0-9][A-Za-z0-9]{21}_[A-Za-z0-9]{59}",
+ #[cfg(test)]
+ TestValue::Multiple(&[
+ "gh1_1234567890abcdefghijk_1234567890abcdefghijklmnopqrstuvwxyz1234567890abcdefghijklm",
+ "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", // also legit, also expired
+ ]),
+ ),
+ (
+ "GitHub OAuth Access Token",
+ "gho_[A-Za-z0-9]{36}",
+ #[cfg(test)]
+ TestValue::Single("gho_1234567890abcdefghijklmnopqrstuvwx000"), // not a real token
+ ),
+ (
+ "GitHub OAuth Access Token (user)",
+ "ghu_[A-Za-z0-9]{36}",
+ #[cfg(test)]
+ TestValue::Single("ghu_1234567890abcdefghijklmnopqrstuvwx000"), // not a real token
+ ),
+ (
+ "GitHub App Installation Access Token",
+ "ghs_[A-Za-z0-9._-]{36,}",
+ #[cfg(test)]
+ TestValue::Multiple(&[
+ "ghs_1234567890abcdefghijklmnopqrstuvwx000", // not a real token
+ "ghs_abc-def.ghi_jklMNOP0123456789qrstuv-wxyzABCD", // new token format, fake data
+ ]),
+ ),
+ (
+ "GitHub Refresh Token",
+ "ghr_[A-Za-z0-9]{76}",
+ #[cfg(test)]
+ TestValue::Single(
+ "ghr_1234567890abcdefghijklmnopqrstuvwx1234567890abcdefghijklmnopqrstuvwx1234567890abcdefghijklmnopqrstuvwx",
+ ), // not a real token
+ ),
+ (
+ "GitHub App Installation Access Token v1",
+ "v1\\.[0-9A-Fa-f]{40}",
+ #[cfg(test)]
+ TestValue::Single("v1.1234567890abcdef1234567890abcdef12345678"), // not a real token
+ ),
+ (
+ "GitLab PAT",
+ "glpat-[a-zA-Z0-9_]{20}",
+ #[cfg(test)]
+ TestValue::Single("glpat-RkE_BG5p_bbjML21WSfy"),
+ ),
+ (
+ "Slack OAuth v2 bot",
+ "xoxb-[0-9]{11}-[0-9]{11}-[0-9a-zA-Z]{24}",
+ #[cfg(test)]
+ TestValue::Single("xoxb-17653672481-19874698323-pdFZKVeTuE8sk7oOcBrzbqgy"),
+ ),
+ (
+ "Slack OAuth v2 user token",
+ "xoxp-[0-9]{11}-[0-9]{11}-[0-9a-zA-Z]{24}",
+ #[cfg(test)]
+ TestValue::Single("xoxp-17653672481-19874698323-pdFZKVeTuE8sk7oOcBrzbqgy"),
+ ),
+ (
+ "Slack webhook",
+ "T[a-zA-Z0-9_]{8}/B[a-zA-Z0-9_]{8}/[a-zA-Z0-9_]{24}",
+ #[cfg(test)]
+ TestValue::Single(
+ "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX",
+ ),
+ ),
+ (
+ "Stripe test key",
+ "sk_test_[0-9a-zA-Z]{24}",
+ #[cfg(test)]
+ TestValue::Single("sk_test_1234567890abcdefghijklmnop"),
+ ),
+ (
+ "Stripe live key",
+ "sk_live_[0-9a-zA-Z]{24}",
+ #[cfg(test)]
+ TestValue::Single("sk_live_1234567890abcdefghijklmnop"),
+ ),
+ (
+ "Netlify authentication token",
+ "nf[pcoub]_[0-9a-zA-Z]{36}",
+ #[cfg(test)]
+ TestValue::Single("nfp_nBh7BdJxUwyaBBwFzpyD29MMFT6pZ9wq5634"),
+ ),
+ (
+ "npm token",
+ "npm_[A-Za-z0-9]{36}",
+ #[cfg(test)]
+ TestValue::Single("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"),
+ ),
+ (
+ "Pulumi personal access token",
+ "pul-[0-9a-f]{40}",
+ #[cfg(test)]
+ TestValue::Single("pul-683c2770662c51d960d72ec27613be7653c5cb26"),
+ ),
+];
+
+/// The `regex` expressions from [`SECRET_PATTERNS`] compiled into a `RegexSet`.
+pub(crate) static SECRET_PATTERNS_RE: LazyLock<RegexSet> = LazyLock::new(|| {
+ let exprs = SECRET_PATTERNS.iter().map(|f| f.1);
+ RegexSet::new(exprs).expect("Failed to build secrets regex")
+});
+
+#[cfg(test)]
+mod tests {
+ use regex::Regex;
+
+ use crate::aclient::secrets::{SECRET_PATTERNS, TestValue};
+
+ #[test]
+ fn test_secrets() {
+ for (name, regex, test) in SECRET_PATTERNS {
+ let re =
+ Regex::new(regex).unwrap_or_else(|_| panic!("Failed to compile regex for {name}"));
+
+ match test {
+ TestValue::Single(test) => {
+ assert!(re.is_match(test), "{name} test failed!");
+ }
+ TestValue::Multiple(tests) => {
+ for test_str in tests.iter() {
+ assert!(
+ re.is_match(test_str),
+ "{name} test with value \"{test_str}\" failed!"
+ );
+ }
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn test_secrets_embedded() {
+ for (name, regex, test) in SECRET_PATTERNS {
+ let re =
+ Regex::new(regex).unwrap_or_else(|_| panic!("Failed to compile regex for {name}"));
+
+ match test {
+ TestValue::Single(test) => {
+ let embedded = format!("some random text {test} some more random text");
+ assert!(re.is_match(&embedded), "{name} embedded test failed!");
+ }
+ TestValue::Multiple(tests) => {
+ for test_str in tests.iter() {
+ let embedded = format!("some random text {test_str} some more random text");
+ assert!(
+ re.is_match(&embedded),
+ "{name} embedded test with value \"{test_str}\" failed!"
+ );
+ }
+ }
+ }
+ }
+ }
+}
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;