From 966a80c4199a49898cc7d8641012d520ce6b2efa Mon Sep 17 00:00:00 2001 From: Benedikt Peetz Date: Mon, 20 Jul 2026 19:30:40 +0200 Subject: chore: Commit --- crates/turtle/src/client/mod.rs | 267 +++++++++++++++++++++++++++++++ crates/turtle/src/generated.rs | 23 +++ crates/turtle/src/history/builder.rs | 78 +++++++++ crates/turtle/src/history/mod.rs | 300 +++++++++++++++++++++++++++++++++++ crates/turtle/src/history/secrets.rs | 223 ++++++++++++++++++++++++++ crates/turtle/src/lib.rs | 5 + 6 files changed, 896 insertions(+) create mode 100644 crates/turtle/src/client/mod.rs create mode 100644 crates/turtle/src/generated.rs create mode 100644 crates/turtle/src/history/builder.rs create mode 100644 crates/turtle/src/history/mod.rs create mode 100644 crates/turtle/src/history/secrets.rs (limited to 'crates/turtle/src') 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 { + 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::().is_some() { + return DaemonClientErrorKind::Connect; + } + + if let Some(status) = cause.downcast_ref::() { + 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, +} + +pub struct Range { + pub start: OffsetDateTime, + pub end: OffsetDateTime, +} + +// Wrap the grpc client +impl HistoryClient { + #[cfg(unix)] + pub async fn new(path: String) -> Result { + 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 { + 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) -> Result> { + 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 { + let req = EndHistoryRequest { id, exit, duration }; + + Ok(self.client.end_history(req).await?.into_inner()) + } + + pub async fn tail_history(&mut self) -> Result> { + Ok(self + .client + .tail_history(TailHistoryRequest {}) + .await? + .into_inner()) + } +} + +// ============================================================================ +// Control Client +// ============================================================================ + +/// Client for the Control gRPC service. +#[derive(Debug)] +pub struct ControlClient { + client: ControlServiceClient, +} + +impl ControlClient { + /// Connect to the daemon's control service. + pub async fn new(path: String) -> Result { + 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 { + Ok(self.client.paths(PathsRequest {}).await?.into_inner()) + } + + pub async fn force_sync(&mut self) -> Result { + Ok(self + .client + .force_sync(ForceSyncRequest {}) + .await? + .into_inner()) + } + + pub async fn status(&mut self) -> Result { + 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, + deleted_at: Option, +} + +impl From 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, + #[builder(default, setter(strip_option, into))] + intent: Option, +} + +impl From 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 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, + + /// Timestamp, which is set when the entry is deleted, allowing a soft delete. + pub deleted_at: Option, +} + +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) -> Option { + 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, + hostname: Option, + author: Option, + intent: Option, + deleted_at: Option, + ) -> 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 = 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; -- cgit v1.3.1