aboutsummaryrefslogtreecommitdiffstats
path: root/crates/client/src/atuin_client
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-07-20 12:57:36 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-07-20 12:57:36 +0200
commit73be69cd99f5a4784fe69f5d78f423c72e837284 (patch)
treecb47246d2c7edd8cd828abb25a033b732d2289ae /crates/client/src/atuin_client
parentchore: Move more stuff out of atuin-client (diff)
downloadatuin-73be69cd99f5a4784fe69f5d78f423c72e837284.zip
chore: Commit
Diffstat (limited to 'crates/client/src/atuin_client')
-rw-r--r--crates/client/src/atuin_client/api_client.rs197
-rw-r--r--crates/client/src/atuin_client/database.rs1313
-rw-r--r--crates/client/src/atuin_client/encryption.rs142
-rw-r--r--crates/client/src/atuin_client/history.rs686
-rw-r--r--crates/client/src/atuin_client/history/builder.rs154
-rw-r--r--crates/client/src/atuin_client/history/store.rs437
-rw-r--r--crates/client/src/atuin_client/meta.rs182
-rw-r--r--crates/client/src/atuin_client/mod.rs10
-rw-r--r--crates/client/src/atuin_client/ordering.rs31
-rw-r--r--crates/client/src/atuin_client/record/encryption.rs379
-rw-r--r--crates/client/src/atuin_client/record/mod.rs3
-rw-r--r--crates/client/src/atuin_client/record/sqlite_store.rs563
-rw-r--r--crates/client/src/atuin_client/record/sync.rs456
-rw-r--r--crates/client/src/atuin_client/secrets.rs223
-rw-r--r--crates/client/src/atuin_client/settings/meta.rs2
-rw-r--r--crates/client/src/atuin_client/settings/mod.rs (renamed from crates/client/src/atuin_client/settings.rs)15
-rw-r--r--crates/client/src/atuin_client/settings/watcher.rs4
-rw-r--r--crates/client/src/atuin_client/utils.rs92
18 files changed, 11 insertions, 4878 deletions
diff --git a/crates/client/src/atuin_client/api_client.rs b/crates/client/src/atuin_client/api_client.rs
deleted file mode 100644
index bd5bf59e..00000000
--- a/crates/client/src/atuin_client/api_client.rs
+++ /dev/null
@@ -1,197 +0,0 @@
-use std::env;
-use std::time::Duration;
-
-use eyre::{Result, bail, eyre};
-use reqwest::{Response, StatusCode, Url, header::HeaderMap};
-use tracing::debug;
-use uuid::Uuid;
-
-use crate::atuin_common::{api::ErrorResponse, record::RecordStatus};
-use crate::atuin_common::{
- api::{ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ATUIN_VERSION},
- record::{EncryptedData, HostId, Record, RecordIdx},
- tls::ensure_crypto_provider,
-};
-
-use semver::Version;
-
-static APP_USER_AGENT: &str = concat!("atuin/", env!("CARGO_PKG_VERSION"),);
-
-pub(crate) struct Client<'a> {
- sync_addr: &'a str,
- user_id: Uuid,
- inner: reqwest::Client,
-}
-
-fn make_url(address: &str, path: &str, user_id: Uuid) -> Result<String> {
- let address = address.strip_suffix('/').unwrap_or(address);
-
- // `join()` expects a trailing `/` in order to join paths
- // e.g. it treats `http://host:port/subdir` as a file called `subdir`
- let address = &format!("{address}/api/v0/{user_id}/");
-
- // passing a path with a leading `/` will cause `join()` to replace the entire URL path
- let path = path.strip_prefix("/").unwrap_or(path);
-
- let url = Url::parse(address)
- .map(|url| url.join(path))?
- .map_err(|_| eyre!("invalid address"))?;
-
- Ok(url.to_string())
-}
-
-pub(crate) fn ensure_version(response: &Response) -> Result<bool> {
- let version = response.headers().get(ATUIN_HEADER_VERSION);
-
- let version = if let Some(version) = version {
- match version.to_str() {
- Ok(v) => Version::parse(v),
- Err(e) => bail!("failed to parse server version: {:?}", e),
- }
- } else {
- bail!("Server not reporting its version: it is either too old or unhealthy");
- }?;
-
- // If the client is newer than the server
- if version.major < ATUIN_VERSION.major {
- println!(
- "Atuin version mismatch! In order to successfully sync, the server needs to run a newer version of Atuin"
- );
- println!("Client: {ATUIN_CARGO_VERSION}");
- println!("Server: {version}");
-
- return Ok(false);
- }
-
- Ok(true)
-}
-
-async fn handle_resp_error(resp: Response) -> Result<Response> {
- let status = resp.status();
- let url = resp.url().to_string();
-
- if status == StatusCode::SERVICE_UNAVAILABLE {
- bail!(
- "Service unavailable: check https://status.atuin.sh (or get in touch with your host)"
- );
- }
-
- if status == StatusCode::TOO_MANY_REQUESTS {
- bail!("Rate limited; please wait before doing that again");
- }
-
- if !status.is_success() {
- if let Ok(error) = resp.json::<ErrorResponse<'_>>().await {
- let reason = error.reason;
-
- if status.is_client_error() {
- bail!("Invalid request to the service at {url}, {status} - {reason}.")
- }
-
- bail!(
- "There was an error with the atuin sync service at {url}, server error {status}: {reason}.\nIf the problem persists, contact the host"
- )
- }
-
- bail!(
- "There was an error with the atuin sync service at {url}, Status {status:?}.\nIf the problem persists, contact the host"
- )
- }
-
- Ok(resp)
-}
-
-impl<'a> Client<'a> {
- pub(crate) fn new(
- sync_addr: &'a str,
- connect_timeout: u64,
- timeout: u64,
- user_id: Uuid,
- ) -> Result<Self> {
- ensure_crypto_provider();
- let mut headers = HeaderMap::new();
-
- // used for semver server check
- headers.insert(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION.parse()?);
-
- Ok(Client {
- user_id,
- sync_addr,
- inner: reqwest::Client::builder()
- .user_agent(APP_USER_AGENT)
- .default_headers(headers)
- .connect_timeout(Duration::new(connect_timeout, 0))
- .timeout(Duration::new(timeout, 0))
- .build()?,
- })
- }
-
- pub(crate) async fn delete_store(&self) -> Result<()> {
- let url = make_url(self.sync_addr, "/store", self.user_id)?;
- let url = Url::parse(url.as_str())?;
-
- let resp = self.inner.delete(url).send().await?;
-
- handle_resp_error(resp).await?;
-
- Ok(())
- }
-
- pub(crate) async fn post_records(&self, records: &[Record<EncryptedData>]) -> Result<()> {
- let url = make_url(self.sync_addr, "/record", self.user_id)?;
- let url = Url::parse(url.as_str())?;
-
- debug!("uploading {} records to {url}", records.len());
-
- let resp = self.inner.post(url).json(records).send().await?;
- handle_resp_error(resp).await?;
-
- Ok(())
- }
-
- pub(crate) async fn next_records(
- &self,
- host: HostId,
- tag: String,
- start: RecordIdx,
- count: u64,
- ) -> Result<Vec<Record<EncryptedData>>> {
- debug!("fetching record/s from host {}/{}/{}", host.0, tag, start);
-
- let url = make_url(
- self.sync_addr,
- &format!(
- "/record/next?host={}&tag={}&count={}&start={}",
- host.0, tag, count, start
- ),
- self.user_id,
- )?;
-
- let url = Url::parse(url.as_str())?;
-
- let resp = self.inner.get(url).send().await?;
- let resp = handle_resp_error(resp).await?;
-
- let records = resp.json::<Vec<Record<EncryptedData>>>().await?;
-
- Ok(records)
- }
-
- pub(crate) async fn record_status(&self) -> Result<RecordStatus> {
- let url = make_url(self.sync_addr, "/record", self.user_id)?;
- let url = Url::parse(url.as_str())?;
-
- let resp = self.inner.get(url).send().await?;
- let resp = handle_resp_error(resp).await?;
-
- if !ensure_version(&resp)? {
- bail!("could not sync records due to version mismatch");
- }
-
- let index = resp.json().await?;
-
- debug!("got remote index {index:?}");
-
- Ok(index)
- }
-}
diff --git a/crates/client/src/atuin_client/database.rs b/crates/client/src/atuin_client/database.rs
deleted file mode 100644
index a9eb2058..00000000
--- a/crates/client/src/atuin_client/database.rs
+++ /dev/null
@@ -1,1313 +0,0 @@
-use std::{
- env,
- path::{Path, PathBuf},
- str::FromStr,
-};
-
-use crate::{atuin_client::utils::setup_db, atuin_common::utils};
-use fs_err::{self as fs};
-use itertools::Itertools;
-use sql_builder::{SqlBuilder, SqlName, bind::Bind, esc, quote};
-use sqlx::{
- Result, Row,
- sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqliteRow, SqliteSynchronous},
-};
-use time::OffsetDateTime;
-use tracing::debug;
-use uuid::Uuid;
-
-use crate::atuin_client::{
- history::{HistoryId, HistoryStats},
- utils::get_host_user,
-};
-
-use super::{
- history::History,
- ordering,
- settings::{FilterMode, SearchMode, Settings},
-};
-
-#[derive(Clone)]
-pub(crate) struct Context {
- pub(crate) session: String,
- pub(crate) cwd: String,
- pub(crate) hostname: String,
- pub(crate) host_id: String,
- pub(crate) git_root: Option<PathBuf>,
-}
-
-#[derive(Default, Clone)]
-pub(crate) struct OptFilters {
- pub(crate) exit: Option<i64>,
- pub(crate) exclude_exit: Option<i64>,
- pub(crate) cwd: Option<String>,
- pub(crate) exclude_cwd: Option<String>,
- pub(crate) before: Option<String>,
- pub(crate) after: Option<String>,
- pub(crate) limit: Option<i64>,
- pub(crate) offset: Option<i64>,
- pub(crate) reverse: bool,
- pub(crate) include_duplicates: bool,
-}
-
-pub(crate) async fn current_context() -> eyre::Result<Context> {
- let session = env::var("ATUIN_SESSION").map_err(|_| {
- eyre::eyre!("Failed to find $ATUIN_SESSION in the environment. Check that you have correctly set up your shell.")
- })?;
- let hostname = get_host_user();
- let cwd = utils::get_current_dir();
- let host_id = Settings::host_id().await?;
- let git_root = utils::in_git_repo(cwd.as_str());
-
- Ok(Context {
- session,
- hostname,
- cwd,
- git_root,
- host_id: host_id.0.as_simple().to_string(),
- })
-}
-
-impl Context {
- pub(crate) fn from_history(entry: &History) -> Self {
- Self {
- session: entry.session.clone(),
- cwd: entry.cwd.clone(),
- hostname: entry.hostname.clone(),
- host_id: String::new(),
- git_root: utils::in_git_repo(entry.cwd.as_str()),
- }
- }
-}
-
-fn get_session_start_time(session_id: &str) -> Option<i64> {
- if let Ok(uuid) = Uuid::parse_str(session_id)
- && let Some(timestamp) = uuid.get_timestamp()
- {
- let (seconds, nanos) = timestamp.to_unix();
- return Some(seconds as i64 * 1_000_000_000 + i64::from(nanos));
- }
- None
-}
-
-// Intended for use on a developer machine and not a sync server.
-// TODO: implement IntoIterator
-#[derive(Debug, Clone)]
-pub(crate) struct ClientSqlite {
- pub(crate) pool: SqlitePool,
-}
-
-impl ClientSqlite {
- pub(crate) async fn new(path: impl AsRef<Path>, timeout: f64) -> Result<Self> {
- fn mk_opts(path: &str) -> Result<SqliteConnectOptions> {
- let opts = SqliteConnectOptions::from_str(path)?
- .journal_mode(SqliteJournalMode::Wal)
- .optimize_on_close(true, None)
- .synchronous(SqliteSynchronous::Normal)
- .with_regexp()
- .create_if_missing(true);
-
- Ok(opts)
- }
-
- let path = path.as_ref();
- debug!("opening sqlite database at {path:?}");
-
- if utils::broken_symlink(path) {
- eprintln!(
- "Atuin: Sqlite db path ({}) is a broken symlink. Unable to read or create replacement.",
- path.display()
- );
- std::process::exit(1);
- }
-
- if !path.exists()
- && let Some(dir) = path.parent()
- {
- fs::create_dir_all(dir)?;
- }
-
- let pool = setup_db!(path, timeout, mk_opts, "./db/client-migrations").await?;
- Ok(Self { pool })
- }
-
- async fn save_raw(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, h: &History) -> Result<()> {
- sqlx::query(
- "insert or ignore into history(id, timestamp, duration, exit, command, cwd, session, hostname, author, intent, deleted_at)
- values(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
- )
- .bind(h.id.0.as_str())
- .bind(h.timestamp.unix_timestamp_nanos() as i64)
- .bind(h.duration)
- .bind(h.exit)
- .bind(h.command.as_str())
- .bind(h.cwd.as_str())
- .bind(h.session.as_str())
- .bind(h.hostname.as_str())
- .bind(h.author.as_str())
- .bind(h.intent.as_deref())
- .bind(h.deleted_at.map(|t|t.unix_timestamp_nanos() as i64))
- .execute(&mut **tx)
- .await?;
-
- Ok(())
- }
-
- async fn delete_row_raw(
- tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
- id: HistoryId,
- ) -> Result<()> {
- sqlx::query("delete from history where id = ?1")
- .bind(id.0.as_str())
- .execute(&mut **tx)
- .await?;
-
- Ok(())
- }
-
- #[expect(clippy::needless_pass_by_value)]
- fn query_history_inner(row: SqliteRow) -> History {
- let deleted_at: Option<i64> = row.get("deleted_at");
- let hostname: String = row.get("hostname");
- let author: Option<String> = row.try_get("author").ok().flatten();
- let author = author
- .filter(|author| !author.trim().is_empty())
- .unwrap_or_else(|| History::author_from_hostname(hostname.as_str()));
- let intent: Option<String> = row.try_get("intent").ok().flatten();
- let intent = intent.filter(|intent| !intent.trim().is_empty());
-
- History::from_db()
- .id(row.get("id"))
- .timestamp(
- OffsetDateTime::from_unix_timestamp_nanos(i128::from(
- row.get::<i64, _>("timestamp"),
- ))
- .unwrap(),
- )
- .duration(row.get("duration"))
- .exit(row.get("exit"))
- .command(row.get("command"))
- .cwd(row.get("cwd"))
- .session(row.get("session"))
- .hostname(hostname)
- .author(author)
- .intent(intent)
- .deleted_at(
- deleted_at
- .and_then(|t| OffsetDateTime::from_unix_timestamp_nanos(i128::from(t)).ok()),
- )
- .build()
- .into()
- }
-}
-
-impl ClientSqlite {
- pub(crate) async fn save(&self, h: &History) -> Result<()> {
- debug!("saving history to sqlite");
- let mut tx = self.pool.begin().await?;
- Self::save_raw(&mut tx, h).await?;
- tx.commit().await?;
-
- Ok(())
- }
-
- pub(crate) async fn save_bulk(&self, h: &[History]) -> Result<()> {
- debug!("saving history to sqlite");
-
- let mut tx = self.pool.begin().await?;
-
- for i in h {
- Self::save_raw(&mut tx, i).await?;
- }
-
- tx.commit().await?;
-
- Ok(())
- }
-
- pub(crate) async fn load(&self, id: &str) -> Result<Option<History>> {
- debug!("loading history item {}", id);
-
- let res = sqlx::query("select * from history where id = ?1")
- .bind(id)
- .map(Self::query_history_inner)
- .fetch_optional(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- // make a unique list, that only shows the *newest* version of things
- pub(crate) async fn list(
- &self,
- filters: &[FilterMode],
- context: &Context,
- max: Option<usize>,
- unique: bool,
- include_deleted: bool,
- ) -> Result<Vec<History>> {
- debug!("listing history");
-
- let mut query = SqlBuilder::select_from(SqlName::new("history").alias("h").baquoted());
- query.field("*").order_desc("timestamp");
- if !include_deleted {
- query.and_where_is_null("deleted_at");
- }
-
- let git_root = context.git_root.clone().map_or_else(
- || context.cwd.clone(),
- |git_root| git_root.to_str().unwrap_or("/").to_string(),
- );
-
- let session_start = get_session_start_time(&context.session);
-
- for filter in filters {
- match filter {
- FilterMode::Global => &mut query,
- FilterMode::Host => query.and_where_eq("hostname", quote(&context.hostname)),
- FilterMode::Session => query.and_where_eq("session", quote(&context.session)),
- FilterMode::SessionPreload => {
- query.and_where_eq("session", quote(&context.session));
- if let Some(session_start) = session_start {
- query.or_where_lt("timestamp", session_start);
- }
- &mut query
- }
- FilterMode::Directory => query.and_where_eq("cwd", quote(&context.cwd)),
- FilterMode::Workspace => query.and_where_like_left("cwd", &git_root),
- };
- }
-
- if unique {
- query.group_by("command").having("max(timestamp)");
- }
-
- if let Some(max) = max {
- query.limit(max);
- }
-
- let query = query.sql().expect("bug in list query. please report");
-
- let res = sqlx::query(&query)
- .map(Self::query_history_inner)
- .fetch_all(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- pub(crate) async fn range(
- &self,
- from: OffsetDateTime,
- to: OffsetDateTime,
- ) -> Result<Vec<History>> {
- debug!("listing history from {:?} to {:?}", from, to);
-
- let res = sqlx::query(
- "select * from history where timestamp >= ?1 and timestamp <= ?2 order by timestamp asc",
- )
- .bind(from.unix_timestamp_nanos() as i64)
- .bind(to.unix_timestamp_nanos() as i64)
- .map(Self::query_history_inner)
- .fetch_all(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- pub(crate) async fn last(&self) -> Result<Option<History>> {
- let res = sqlx::query(
- "select * from history where duration >= 0 order by timestamp desc limit 1",
- )
- .map(Self::query_history_inner)
- .fetch_optional(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- pub(crate) async fn history_count(&self, include_deleted: bool) -> Result<i64> {
- let query = if include_deleted {
- "select count(1) from history"
- } else {
- "select count(1) from history where deleted_at is null"
- };
-
- let res: (i64,) = sqlx::query_as(query).fetch_one(&self.pool).await?;
- Ok(res.0)
- }
-
- // Yes I know, it's a lot.
- // Could maybe break it down to a searchparams struct or smth but that feels a little... pointless.
- // Been debating maybe a DSL for search? eg "before:time limit:1 the query"
- #[expect(clippy::too_many_lines)]
- pub(crate) async fn search(
- &self,
- search_mode: SearchMode,
- filter: FilterMode,
- context: &Context,
- query: &str,
- filter_options: OptFilters,
- ) -> Result<Vec<History>> {
- let mut sql = SqlBuilder::select_from("history");
-
- if !filter_options.include_duplicates {
- sql.group_by("command").having("max(timestamp)");
- }
-
- if let Some(limit) = filter_options.limit {
- sql.limit(limit);
- }
-
- if let Some(offset) = filter_options.offset {
- sql.offset(offset);
- }
-
- if filter_options.reverse {
- sql.order_asc("timestamp");
- } else {
- sql.order_desc("timestamp");
- }
-
- let git_root = context.git_root.clone().map_or_else(
- || context.cwd.clone(),
- |git_root| git_root.to_str().unwrap_or("/").to_string(),
- );
-
- let session_start = get_session_start_time(&context.session);
-
- match filter {
- FilterMode::Global => &mut sql,
- FilterMode::Host => {
- sql.and_where_eq("lower(hostname)", quote(context.hostname.to_lowercase()))
- }
- FilterMode::Session => sql.and_where_eq("session", quote(&context.session)),
- FilterMode::SessionPreload => {
- sql.and_where_eq("session", quote(&context.session));
- if let Some(session_start) = session_start {
- sql.or_where_lt("timestamp", session_start);
- }
- &mut sql
- }
- FilterMode::Directory => sql.and_where_eq("cwd", quote(&context.cwd)),
- FilterMode::Workspace => sql.and_where_like_left("cwd", git_root),
- };
-
- let orig_query = query;
-
- let mut regexes = Vec::new();
- if search_mode == SearchMode::Prefix {
- sql.and_where_like_left("command", query.replace('*', "%"))
- } else {
- let mut is_or = false;
- for token in QueryTokenizer::new(query) {
- // TODO smart case mode could be made configurable like in fzf
- let (is_glob, glob) = if token.has_uppercase() {
- (true, "*")
- } else {
- (false, "%")
- };
- let param = match token {
- QueryToken::Regex(r) => {
- regexes.push(String::from(r));
- continue;
- }
- QueryToken::Or => {
- if !is_or {
- is_or = true;
- continue;
- }
-
- format!("{glob}|{glob}")
- }
- QueryToken::MatchStart(term, _) => {
- format!("{term}{glob}")
- }
- QueryToken::MatchEnd(term, _) => {
- format!("{glob}{term}")
- }
- QueryToken::MatchFull(term, _) => {
- format!("{glob}{term}{glob}")
- }
- QueryToken::Match(term, _) => {
- if search_mode == SearchMode::FullText {
- format!("{glob}{term}{glob}")
- } else {
- term.split("").join(glob)
- }
- }
- };
-
- sql.fuzzy_condition("command", param, token.is_inverse(), is_glob, is_or);
- is_or = false;
- }
-
- &mut sql
- };
-
- for regex in regexes {
- sql.and_where("command regexp ?".bind(&regex));
- }
-
- filter_options
- .exit
- .map(|exit| sql.and_where_eq("exit", exit));
-
- filter_options
- .exclude_exit
- .map(|exclude_exit| sql.and_where_ne("exit", exclude_exit));
-
- filter_options
- .cwd
- .map(|cwd| sql.and_where_eq("cwd", quote(cwd)));
-
- filter_options
- .exclude_cwd
- .map(|exclude_cwd| sql.and_where_ne("cwd", quote(exclude_cwd)));
-
- filter_options.before.map(|before| {
- interim::parse_date_string(
- before.as_str(),
- OffsetDateTime::now_utc(),
- interim::Dialect::Uk,
- )
- .map(|before| {
- sql.and_where_lt("timestamp", quote(before.unix_timestamp_nanos() as i64))
- })
- });
-
- filter_options.after.map(|after| {
- interim::parse_date_string(
- after.as_str(),
- OffsetDateTime::now_utc(),
- interim::Dialect::Uk,
- )
- .map(|after| sql.and_where_gt("timestamp", quote(after.unix_timestamp_nanos() as i64)))
- });
-
- sql.and_where_is_null("deleted_at");
-
- let query = sql.sql().expect("bug in search query. please report");
-
- let res = sqlx::query(&query)
- .map(Self::query_history_inner)
- .fetch_all(&self.pool)
- .await?;
-
- Ok(ordering::reorder_fuzzy(search_mode, orig_query, res))
- }
-
- pub(crate) async fn query_history(&self, query: &str) -> Result<Vec<History>> {
- let res = sqlx::query(query)
- .map(Self::query_history_inner)
- .fetch_all(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- pub(crate) async fn all_with_count(&self) -> Result<Vec<(History, i32)>> {
- debug!("listing history");
-
- let mut query = SqlBuilder::select_from(SqlName::new("history").alias("h").baquoted());
-
- query
- .fields(&[
- "id",
- "max(timestamp) as timestamp",
- "max(duration) as duration",
- "exit",
- "command",
- "deleted_at",
- "null as author",
- "null as intent",
- "group_concat(cwd, ':') as cwd",
- "group_concat(session) as session",
- "group_concat(hostname, ',') as hostname",
- "count(*) as count",
- ])
- .group_by("command")
- .group_by("exit")
- .and_where("deleted_at is null")
- .order_desc("timestamp");
-
- let query = query.sql().expect("bug in list query. please report");
-
- let res = sqlx::query(&query)
- .map(|row: SqliteRow| {
- let count: i32 = row.get("count");
- (Self::query_history_inner(row), count)
- })
- .fetch_all(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- pub(crate) fn all_paged(&self, page_size: usize, include_deleted: bool, unique: bool) -> Paged {
- Paged::new(self.clone(), page_size, include_deleted, unique)
- }
-
- pub(crate) async fn delete_rows(&self, ids: &[HistoryId]) -> Result<()> {
- let mut tx = self.pool.begin().await?;
-
- for id in ids {
- Self::delete_row_raw(&mut tx, id.clone()).await?;
- }
-
- tx.commit().await?;
-
- Ok(())
- }
-
- pub(crate) async fn stats(&self, h: &History) -> Result<HistoryStats> {
- // We select the previous in the session by time
- let mut prev = SqlBuilder::select_from("history");
- prev.field("*")
- .and_where("timestamp < ?1")
- .and_where("session = ?2")
- .order_by("timestamp", true)
- .limit(1);
-
- let mut next = SqlBuilder::select_from("history");
- next.field("*")
- .and_where("timestamp > ?1")
- .and_where("session = ?2")
- .order_by("timestamp", false)
- .limit(1);
-
- let mut total = SqlBuilder::select_from("history");
- total.field("count(1)").and_where("command = ?1");
-
- let mut average = SqlBuilder::select_from("history");
- average.field("avg(duration)").and_where("command = ?1");
-
- let mut exits = SqlBuilder::select_from("history");
- exits
- .fields(&["exit", "count(1) as count"])
- .and_where("command = ?1")
- .group_by("exit");
-
- // rewrite the following with sqlbuilder
- let mut day_of_week = SqlBuilder::select_from("history");
- day_of_week
- .fields(&[
- "strftime('%w', ROUND(timestamp / 1000000000), 'unixepoch') AS day_of_week",
- "count(1) as count",
- ])
- .and_where("command = ?1")
- .group_by("day_of_week");
-
- // Intentionally format the string with 01 hardcoded. We want the average runtime for the
- // _entire month_, but will later parse it as a datetime for sorting
- // Sqlite has no datetime so we cannot do it there, and otherwise sorting will just be a
- // string sort, which won't be correct.
- let mut duration_over_time = SqlBuilder::select_from("history");
- duration_over_time
- .fields(&[
- "strftime('01-%m-%Y', ROUND(timestamp / 1000000000), 'unixepoch') AS month_year",
- "avg(duration) as duration",
- ])
- .and_where("command = ?1")
- .group_by("month_year")
- .having("duration > 0");
-
- let prev = prev.sql().expect("issue in stats previous query");
- let next = next.sql().expect("issue in stats next query");
- let total = total.sql().expect("issue in stats average query");
- let average = average.sql().expect("issue in stats previous query");
- let exits = exits.sql().expect("issue in stats exits query");
- let day_of_week = day_of_week.sql().expect("issue in stats day of week query");
- let duration_over_time = duration_over_time
- .sql()
- .expect("issue in stats duration over time query");
-
- let prev = sqlx::query(&prev)
- .bind(h.timestamp.unix_timestamp_nanos() as i64)
- .bind(&h.session)
- .map(Self::query_history_inner)
- .fetch_optional(&self.pool)
- .await?;
-
- let next = sqlx::query(&next)
- .bind(h.timestamp.unix_timestamp_nanos() as i64)
- .bind(&h.session)
- .map(Self::query_history_inner)
- .fetch_optional(&self.pool)
- .await?;
-
- let total: (i64,) = sqlx::query_as(&total)
- .bind(&h.command)
- .fetch_one(&self.pool)
- .await?;
-
- let average: (f64,) = sqlx::query_as(&average)
- .bind(&h.command)
- .fetch_one(&self.pool)
- .await?;
-
- let exits: Vec<(i64, i64)> = sqlx::query_as(&exits)
- .bind(&h.command)
- .fetch_all(&self.pool)
- .await?;
-
- let day_of_week: Vec<(String, i64)> = sqlx::query_as(&day_of_week)
- .bind(&h.command)
- .fetch_all(&self.pool)
- .await?;
-
- let duration_over_time: Vec<(String, f64)> = sqlx::query_as(&duration_over_time)
- .bind(&h.command)
- .fetch_all(&self.pool)
- .await?;
-
- let duration_over_time = duration_over_time
- .iter()
- .map(|f| (f.0.clone(), f.1.round() as i64))
- .collect();
-
- Ok(HistoryStats {
- next,
- previous: prev,
- total: total.0 as u64,
- average_duration: average.0 as u64,
- exits,
- day_of_week,
- duration_over_time,
- })
- }
-
- pub(crate) async fn get_dups(&self, before: i64, dupkeep: u32) -> Result<Vec<History>> {
- let res = sqlx::query(
- "SELECT * FROM (
- SELECT *, ROW_NUMBER()
- OVER (PARTITION BY command, cwd, hostname ORDER BY timestamp DESC)
- AS rn
- FROM history
- ) sub
- WHERE rn > ?1 and timestamp < ?2;
- ",
- )
- .bind(dupkeep)
- .bind(before)
- .map(Self::query_history_inner)
- .fetch_all(&self.pool)
- .await?;
-
- Ok(res)
- }
-}
-
-pub(crate) struct Paged {
- database: ClientSqlite,
- page_size: usize,
- last_id: Option<String>,
- include_deleted: bool,
- unique: bool,
-}
-
-impl Paged {
- pub(crate) fn new(
- database: ClientSqlite,
- page_size: usize,
- include_deleted: bool,
- unique: bool,
- ) -> Self {
- Self {
- database,
- page_size,
- last_id: None,
- include_deleted,
- unique,
- }
- }
-
- pub(crate) async fn next(&mut self) -> Result<Option<Vec<History>>> {
- let mut query = SqlBuilder::select_from(SqlName::new("history").alias("h").baquoted());
-
- query.field("*").order_desc("id");
-
- if !self.include_deleted {
- query.and_where_is_null("deleted_at");
- }
-
- if self.unique {
- // We want to deduplicate on command, but the user can search via cwd, hostname, and session.
- // Without those fields, filter modes won't work right. With those fields, we get duplicates.
- // This must be handled upstream.
- query
- .group_by("command, cwd, hostname, session")
- .having("max(timestamp)");
- }
-
- query.limit(self.page_size);
-
- if let Some(last_id) = &self.last_id {
- query.and_where_lt("id", quote(last_id));
- }
-
- let query = query.sql().expect("bug in list query. please report");
- let res = self.database.query_history(&query).await?;
-
- if res.is_empty() {
- Ok(None)
- } else {
- self.last_id = Some(res.last().unwrap().id.0.clone());
- Ok(Some(res))
- }
- }
-}
-
-trait SqlBuilderExt {
- fn fuzzy_condition<S: ToString, T: ToString>(
- &mut self,
- field: S,
- mask: T,
- inverse: bool,
- glob: bool,
- is_or: bool,
- ) -> &mut Self;
-}
-
-impl SqlBuilderExt for SqlBuilder {
- /// adapted from the sql-builder *like functions
- fn fuzzy_condition<S: ToString, T: ToString>(
- &mut self,
- field: S,
- mask: T,
- inverse: bool,
- glob: bool,
- is_or: bool,
- ) -> &mut Self {
- let mut cond = field.to_string();
- if inverse {
- cond.push_str(" NOT");
- }
- if glob {
- cond.push_str(" GLOB '");
- } else {
- cond.push_str(" LIKE '");
- }
- cond.push_str(&esc(mask.to_string()));
- cond.push('\'');
- if is_or {
- self.or_where(cond)
- } else {
- self.and_where(cond)
- }
- }
-}
-
-#[cfg(test)]
-mod test {
- use crate::atuin_client::settings::test_local_timeout;
-
- use super::{
- ClientSqlite, Context, FilterMode, History, OffsetDateTime, OptFilters, Result, SearchMode,
- };
- use std::time::{Duration, Instant};
-
- async fn assert_search_eq(
- db: &ClientSqlite,
- mode: SearchMode,
- filter_mode: FilterMode,
- query: &str,
- expected: usize,
- ) -> Result<Vec<History>> {
- let context = Context {
- hostname: "test:host".to_string(),
- session: "beepboopiamasession".to_string(),
- cwd: "/home/ellie".to_string(),
- host_id: "test-host".to_string(),
- git_root: None,
- };
-
- let results = db
- .search(
- mode,
- filter_mode,
- &context,
- query,
- OptFilters {
- ..Default::default()
- },
- )
- .await?;
-
- assert_eq!(
- results.len(),
- expected,
- "query \"{}\", commands: {:?}",
- query,
- results.iter().map(|a| &a.command).collect::<Vec<&String>>()
- );
- Ok(results)
- }
-
- async fn assert_search_commands(
- db: &ClientSqlite,
- mode: SearchMode,
- filter_mode: FilterMode,
- query: &str,
- expected_commands: Vec<&str>,
- ) {
- let results = assert_search_eq(db, mode, filter_mode, query, expected_commands.len())
- .await
- .unwrap();
- let commands: Vec<&str> = results.iter().map(|a| a.command.as_str()).collect();
- assert_eq!(commands, expected_commands);
- }
-
- async fn new_history_item(db: &mut ClientSqlite, cmd: &str) -> Result<()> {
- let mut captured: History = History::capture()
- .timestamp(OffsetDateTime::now_utc())
- .command(cmd)
- .cwd("/home/ellie")
- .build()
- .into();
-
- captured.exit = 0;
- captured.duration = 1;
- captured.session = "beep boop".to_string();
- captured.hostname = "booop".to_string();
-
- db.save(&captured).await
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn test_search_prefix() {
- let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
- .await
- .unwrap();
- new_history_item(&mut db, "ls /home/ellie").await.unwrap();
-
- assert_search_eq(&db, SearchMode::Prefix, FilterMode::Global, "ls", 1)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Prefix, FilterMode::Global, "/home", 0)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Prefix, FilterMode::Global, "ls ", 0)
- .await
- .unwrap();
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn test_search_fulltext() {
- let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
- .await
- .unwrap();
- new_history_item(&mut db, "ls /home/ellie").await.unwrap();
-
- assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "ls", 1)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "/home", 1)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "ls ho", 1)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "hm", 0)
- .await
- .unwrap();
-
- // regex
- assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "r/^ls ", 1)
- .await
- .unwrap();
- assert_search_eq(
- &db,
- SearchMode::FullText,
- FilterMode::Global,
- "r/ls / ie$",
- 1,
- )
- .await
- .unwrap();
- assert_search_eq(
- &db,
- SearchMode::FullText,
- FilterMode::Global,
- "r/ls / !ie",
- 0,
- )
- .await
- .unwrap();
- assert_search_eq(
- &db,
- SearchMode::FullText,
- FilterMode::Global,
- "meow r/ls/",
- 0,
- )
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "r//hom/", 1)
- .await
- .unwrap();
- assert_search_eq(
- &db,
- SearchMode::FullText,
- FilterMode::Global,
- "r//home//",
- 1,
- )
- .await
- .unwrap();
- assert_search_eq(
- &db,
- SearchMode::FullText,
- FilterMode::Global,
- "r//home///",
- 0,
- )
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "/home.*e", 0)
- .await
- .unwrap();
- assert_search_eq(
- &db,
- SearchMode::FullText,
- FilterMode::Global,
- "r/home.*e",
- 1,
- )
- .await
- .unwrap();
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn test_search_fuzzy() {
- let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
- .await
- .unwrap();
- new_history_item(&mut db, "ls /home/ellie").await.unwrap();
- new_history_item(&mut db, "ls /home/frank").await.unwrap();
- new_history_item(&mut db, "cd /home/Ellie").await.unwrap();
- new_history_item(&mut db, "/home/ellie/.bin/rustup")
- .await
- .unwrap();
-
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ls /", 3)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ls/", 2)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "l/h/", 2)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "/h/e", 3)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "/hmoe/", 0)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ellie/home", 0)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "lsellie", 1)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, " ", 4)
- .await
- .unwrap();
-
- // single term operators
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "^ls", 2)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "'ls", 2)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ellie$", 2)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "!^ls", 2)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "!ellie", 1)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "!ellie$", 2)
- .await
- .unwrap();
-
- // multiple terms
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ls !ellie", 1)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "^ls !e$", 1)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "home !^ls", 2)
- .await
- .unwrap();
- assert_search_eq(
- &db,
- SearchMode::Fuzzy,
- FilterMode::Global,
- "'frank | 'rustup",
- 2,
- )
- .await
- .unwrap();
- assert_search_eq(
- &db,
- SearchMode::Fuzzy,
- FilterMode::Global,
- "'frank | 'rustup 'ls",
- 1,
- )
- .await
- .unwrap();
-
- // case matching
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "Ellie", 1)
- .await
- .unwrap();
-
- // regex
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "r/^ls ", 2)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "r/[Ee]llie", 3)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "/h/e r/^ls ", 1)
- .await
- .unwrap();
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn test_search_reordered_fuzzy() {
- let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
- .await
- .unwrap();
- // test ordering of results: we should choose the first, even though it happened longer ago.
-
- new_history_item(&mut db, "curl").await.unwrap();
- new_history_item(&mut db, "corburl").await.unwrap();
-
- // if fuzzy reordering is on, it should come back in a more sensible order
- assert_search_commands(
- &db,
- SearchMode::Fuzzy,
- FilterMode::Global,
- "curl",
- vec!["curl", "corburl"],
- )
- .await;
-
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "xxxx", 0)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "", 2)
- .await
- .unwrap();
- }
-
- #[tokio::test(flavor = "multi_thread")]
- #[expect(clippy::similar_names)]
- async fn test_paged_basic() {
- let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
- .await
- .unwrap();
-
- // Add 5 history items
- for i in 0..5 {
- new_history_item(&mut db, &format!("command{}", i))
- .await
- .unwrap();
- }
-
- // Create a paged iterator with page_size of 2
- let mut paged = db.all_paged(2, false, false);
-
- // First page should have 2 items
- let page1 = paged.next().await.unwrap();
- assert!(page1.is_some());
- assert_eq!(page1.unwrap().len(), 2);
-
- // Second page should have 2 items
- let page2 = paged.next().await.unwrap();
- assert!(page2.is_some());
- assert_eq!(page2.unwrap().len(), 2);
-
- // Third page should have 1 item
- let page3 = paged.next().await.unwrap();
- assert!(page3.is_some());
- assert_eq!(page3.unwrap().len(), 1);
-
- // Fourth page should be None (exhausted)
- let page4 = paged.next().await.unwrap();
- assert!(page4.is_none());
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn test_paged_empty() {
- let db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
- .await
- .unwrap();
-
- // Create a paged iterator on empty database
- let mut paged = db.all_paged(10, false, false);
-
- // Should return None immediately
- let page = paged.next().await.unwrap();
- assert!(page.is_none());
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn test_paged_unique() {
- let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
- .await
- .unwrap();
-
- // Add duplicate commands
- new_history_item(&mut db, "duplicate").await.unwrap();
- new_history_item(&mut db, "duplicate").await.unwrap();
- new_history_item(&mut db, "unique1").await.unwrap();
- new_history_item(&mut db, "unique2").await.unwrap();
-
- // Without unique flag - should get all 4
- let mut paged = db.all_paged(10, false, false);
- let page = paged.next().await.unwrap().unwrap();
- assert_eq!(page.len(), 4);
-
- // With unique flag - should get 3 (duplicates collapsed)
- let mut paged_unique = db.all_paged(10, false, true);
- let paged_unique = paged_unique.next().await.unwrap().unwrap();
- assert_eq!(paged_unique.len(), 3);
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn test_search_bench_dupes() {
- let context = Context {
- hostname: "test:host".to_string(),
- session: "beepboopiamasession".to_string(),
- cwd: "/home/ellie".to_string(),
- host_id: "test-host".to_string(),
- git_root: None,
- };
-
- let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
- .await
- .unwrap();
- for _i in 1..10000 {
- new_history_item(&mut db, "i am a duplicated command")
- .await
- .unwrap();
- }
- let start = Instant::now();
- let _results = db
- .search(
- SearchMode::Fuzzy,
- FilterMode::Global,
- &context,
- "",
- OptFilters {
- ..Default::default()
- },
- )
- .await
- .unwrap();
- let duration = start.elapsed();
-
- assert!(duration < Duration::from_secs(15));
- }
-}
-
-pub(crate) struct QueryTokenizer<'a> {
- query: &'a str,
- last_pos: usize,
-}
-
-pub(crate) enum QueryToken<'a> {
- Match(&'a str, bool),
- MatchStart(&'a str, bool),
- MatchEnd(&'a str, bool),
- MatchFull(&'a str, bool),
- Or,
- Regex(&'a str),
-}
-
-impl QueryToken<'_> {
- pub(crate) fn has_uppercase(&self) -> bool {
- match self {
- Self::Match(term, _)
- | Self::MatchStart(term, _)
- | Self::MatchEnd(term, _)
- | Self::MatchFull(term, _) => term.contains(char::is_uppercase),
- _ => false,
- }
- }
-
- pub(crate) fn is_inverse(&self) -> bool {
- match self {
- Self::Match(_, inv)
- | Self::MatchStart(_, inv)
- | Self::MatchEnd(_, inv)
- | Self::MatchFull(_, inv) => *inv,
- _ => false,
- }
- }
-}
-
-impl<'a> QueryTokenizer<'a> {
- pub(crate) fn new(query: &'a str) -> Self {
- Self { query, last_pos: 0 }
- }
-}
-
-impl<'a> Iterator for QueryTokenizer<'a> {
- type Item = QueryToken<'a>;
- fn next(&mut self) -> Option<Self::Item> {
- let remaining = &self.query[self.last_pos..];
- if remaining.is_empty() {
- return None;
- }
-
- if let Some(remaining) = remaining.strip_prefix("r/") {
- let (regex, next_pos) = if let Some(end) = remaining.find("/ ") {
- (&remaining[..end], self.last_pos + 2 + end + 2)
- } else if let Some(remaining) = remaining.strip_suffix('/') {
- (remaining, self.query.len())
- } else {
- (remaining, self.query.len())
- };
- self.last_pos = next_pos;
- Some(QueryToken::Regex(regex))
- } else {
- let (mut part, next_pos) = if let Some(sp) = remaining.find(' ') {
- (&remaining[..sp], self.last_pos + sp + 1)
- } else {
- (remaining, self.query.len())
- };
- self.last_pos = next_pos;
-
- if part == "|" {
- return Some(QueryToken::Or);
- }
-
- let is_inverse = part.strip_prefix('!').is_some_and(|s| {
- part = s;
- true
- });
-
- #[expect(clippy::option_if_let_else, reason = "It's too ugly")]
- let token = if let Some(s) = part.strip_prefix('^') {
- QueryToken::MatchStart(s, is_inverse)
- } else if let Some(s) = part.strip_suffix('$') {
- QueryToken::MatchEnd(s, is_inverse)
- } else if let Some(s) = part.strip_prefix('\'') {
- QueryToken::MatchFull(s, is_inverse)
- } else {
- QueryToken::Match(part, is_inverse)
- };
- Some(token)
- }
- }
-}
diff --git a/crates/client/src/atuin_client/encryption.rs b/crates/client/src/atuin_client/encryption.rs
deleted file mode 100644
index f1c921cb..00000000
--- a/crates/client/src/atuin_client/encryption.rs
+++ /dev/null
@@ -1,142 +0,0 @@
-// The general idea is that we NEVER send cleartext history to the server
-// This way the odds of anything private ending up where it should not are
-// very low
-// The server authenticates via the usual username and password. This has
-// nothing to do with the encryption, and is purely authentication! The client
-// generates its own secret key, and encrypts all shell history with libsodium's
-// secretbox. The data is then sent to the server, where it is stored. All
-// clients must share the secret in order to be able to sync, as it is needed
-// to decrypt
-
-use std::io::prelude::Write;
-
-use base64::prelude::{BASE64_STANDARD, Engine};
-pub(crate) use crypto_secretbox::Key;
-use crypto_secretbox::{KeyInit, XSalsa20Poly1305, aead::OsRng};
-use eyre::{Context, Result, bail, ensure, eyre};
-use fs_err as fs;
-use rmp::Marker;
-
-use crate::atuin_client::settings::Settings;
-
-pub(crate) fn generate_encoded_key() -> Result<(Key, String)> {
- let key = XSalsa20Poly1305::generate_key(&mut OsRng);
- let encoded = encode_key(&key)?;
-
- Ok((key, encoded))
-}
-
-pub(crate) fn new_key(settings: &Settings) -> Result<Key> {
- if settings.sync.encryption_key()?.is_some() {
- bail!("key already exists! cannot overwrite");
- } else if let Some(path) = settings.sync.encryption_key_path.as_ref() {
- let (key, encoded) = generate_encoded_key()?;
-
- let mut file = fs::File::create(path)?;
- file.write_all(encoded.as_bytes())?;
-
- Ok(key)
- } else {
- bail!("No key-path set, cannot generate key")
- }
-}
-
-// Loads the secret key, will create + save if it doesn't exist
-pub(crate) fn load_key(settings: &Settings) -> Result<Key> {
- if let Some(key) = settings.sync.encryption_key()? {
- Ok(key)
- } else {
- Ok(new_key(settings)?)
- }
-}
-
-pub(crate) fn encode_key(key: &Key) -> Result<String> {
- let mut buf = vec![];
- rmp::encode::write_array_len(&mut buf, key.len() as u32)
- .wrap_err("could not encode key to message pack")?;
- for b in key {
- rmp::encode::write_uint(&mut buf, u64::from(*b))
- .wrap_err("could not encode key to message pack")?;
- }
- let buf = BASE64_STANDARD.encode(buf);
-
- Ok(buf)
-}
-
-pub(crate) fn decode_key(key: &str) -> Result<Key> {
- use rmp::decode;
-
- let buf = BASE64_STANDARD
- .decode(key.trim_end())
- .wrap_err("encryption key is not a valid base64 encoding")?;
-
- // old code wrote the key as a fixed length array of 32 bytes
- // new code writes the key with a length prefix
- if let Ok(key) = <[u8; 32]>::try_from(&*buf) {
- Ok(key.into())
- } else {
- let mut bytes = decode::Bytes::new(&buf);
-
- match Marker::from_u8(buf[0]) {
- Marker::Bin8 => {
- let len = decode::read_bin_len(&mut bytes).map_err(|err| eyre!("{err:?}"))?;
- ensure!(len == 32, "encryption key is not the correct size");
- let key = <[u8; 32]>::try_from(bytes.remaining_slice())
- .context("could not decode encryption key")?;
- Ok(key.into())
- }
- Marker::Array16 => {
- let len = decode::read_array_len(&mut bytes).map_err(|err| eyre!("{err:?}"))?;
- ensure!(len == 32, "encryption key is not the correct size");
-
- let mut key = Key::default();
- for i in &mut key {
- *i = decode::read_int(&mut bytes).map_err(|err| eyre!("{err:?}"))?;
- }
- Ok(key)
- }
- _ => bail!("could not decode encryption key"),
- }
- }
-}
-
-#[cfg(test)]
-mod test {
- #[test]
- fn key_encodings() {
- use super::{Key, decode_key, encode_key};
-
- // a history of our key encodings.
- // v11.0.0 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==
- // v12.0.0 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==
- // v13.0.0 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==
- // v13.0.1 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==
- // v14.0.0 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==
- // v14.0.1 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==
- // c7d89c1 3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q== (https://github.com/ellie/atuin/pull/805)
- // b53ca35 3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q== (https://github.com/ellie/atuin/pull/974)
- // v15.0.0 3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q==
- // b8b57c8 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q== (https://github.com/ellie/atuin/pull/1057)
- // 8c94d79 3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q== (https://github.com/ellie/atuin/pull/1089)
-
- let key = Key::from([
- 27, 91, 42, 91, 210, 107, 9, 216, 170, 190, 242, 62, 6, 84, 69, 148, 148, 53, 251, 117,
- 226, 167, 173, 52, 82, 34, 138, 110, 169, 124, 92, 229,
- ]);
-
- assert_eq!(
- encode_key(&key).unwrap(),
- "3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q=="
- );
-
- // key encodings we have to support
- let valid_encodings = [
- "xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==",
- "3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q==",
- ];
-
- for k in valid_encodings {
- assert_eq!(decode_key(k).expect(k), key);
- }
- }
-}
diff --git a/crates/client/src/atuin_client/history.rs b/crates/client/src/atuin_client/history.rs
deleted file mode 100644
index c38d8ccc..00000000
--- a/crates/client/src/atuin_client/history.rs
+++ /dev/null
@@ -1,686 +0,0 @@
-use core::fmt::Formatter;
-use rmp::decode::DecodeStringError;
-use rmp::decode::ValueReadError;
-use rmp::{Marker, decode::Bytes};
-use std::env;
-use std::fmt::Display;
-
-use crate::atuin_common::record::DecryptedData;
-use crate::atuin_common::utils::uuid_v7;
-
-use eyre::{Result, bail, eyre};
-
-use crate::atuin_client::secrets::SECRET_PATTERNS_RE;
-use crate::atuin_client::settings::Settings;
-use crate::atuin_client::utils::get_host_user;
-use time::OffsetDateTime;
-
-mod builder;
-pub(crate) mod store;
-
-pub(crate) const HISTORY_VERSION_V0: &str = "v0";
-pub(crate) const HISTORY_VERSION_V1: &str = "v1";
-const HISTORY_RECORD_VERSION_V0: u16 = 0;
-const HISTORY_RECORD_VERSION_V1: u16 = 1;
-pub(crate) const HISTORY_VERSION: &str = HISTORY_VERSION_V1;
-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(crate) struct HistoryId(pub(crate) 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(crate) struct History {
- /// A client-generated ID, used to identify the entry when syncing.
- ///
- /// Stored as `client_id` in the database.
- pub(crate) id: HistoryId,
-
- /// When the command was run.
- pub(crate) timestamp: OffsetDateTime,
-
- /// How long the command took to run.
- pub(crate) duration: i64,
-
- /// The exit code of the command.
- pub(crate) exit: i64,
-
- /// The command that was run.
- pub(crate) command: String,
-
- /// The current working directory when the command was run.
- pub(crate) cwd: String,
-
- /// The session ID, associated with a terminal session.
- pub(crate) session: String,
-
- /// The hostname of the machine the command was run on.
- pub(crate) hostname: String,
-
- /// Who wrote this command (human user or automation/agent identity).
- pub(crate) author: String,
-
- /// Optional rationale for why the command was executed.
- pub(crate) intent: Option<String>,
-
- /// Timestamp, which is set when the entry is deleted, allowing a soft delete.
- pub(crate) 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
- pub(crate) next: Option<History>,
-
- /// The command that was ran before this one in the session
- pub(crate) previous: Option<History>,
-
- /// How many times has this command been ran?
- pub(crate) total: u64,
-
- pub(crate) average_duration: u64,
-
- pub(crate) exits: Vec<(i64, i64)>,
-
- pub(crate) day_of_week: Vec<(String, i64)>,
-
- 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) 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.
-
- use rmp::encode;
-
- let mut output = vec![];
-
- // write the version
- encode::write_u16(&mut output, HISTORY_RECORD_VERSION_V1)?;
- let include_intent = self.intent.is_some();
- encode::write_array_len(&mut output, 10 + u32::from(include_intent))?;
-
- encode::write_str(&mut output, &self.id.0)?;
- encode::write_u64(&mut output, self.timestamp.unix_timestamp_nanos() as u64)?;
- encode::write_sint(&mut output, self.duration)?;
- encode::write_sint(&mut output, self.exit)?;
- encode::write_str(&mut output, &self.command)?;
- encode::write_str(&mut output, &self.cwd)?;
- encode::write_str(&mut output, &self.session)?;
- encode::write_str(&mut output, &self.hostname)?;
-
- match self.deleted_at {
- Some(d) => encode::write_u64(&mut output, d.unix_timestamp_nanos() as u64)?,
- None => encode::write_nil(&mut output)?,
- }
-
- encode::write_str(&mut output, self.author.as_str())?;
- if let Some(intent) = &self.intent {
- encode::write_str(&mut output, intent.as_str())?;
- }
-
- Ok(DecryptedData(output))
- }
-
- fn read_optional_string(bytes: &[u8]) -> Result<(Option<String>, &[u8])> {
- use rmp::decode;
-
- fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {
- eyre!("{err:?}")
- }
-
- match decode::read_str_from_slice(bytes) {
- Ok((value, bytes)) => Ok((Some(value.to_owned()), bytes)),
- Err(DecodeStringError::TypeMismatch(Marker::Null)) => {
- let mut cursor = Bytes::new(bytes);
- decode::read_nil(&mut cursor).map_err(error_report)?;
-
- Ok((None, cursor.remaining_slice()))
- }
- Err(err) => Err(error_report(err)),
- }
- }
-
- fn deserialize_v0(bytes: &[u8]) -> Result<Self> {
- use rmp::decode;
-
- fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {
- eyre!("{err:?}")
- }
-
- let mut bytes = Bytes::new(bytes);
-
- let version = decode::read_u16(&mut bytes).map_err(error_report)?;
-
- if version != HISTORY_RECORD_VERSION_V0 {
- bail!("expected decoding v0 record, found v{version}");
- }
-
- let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?;
-
- if nfields != 9 {
- bail!("cannot decrypt history from a different version of Atuin");
- }
-
- let bytes = bytes.remaining_slice();
- let (id, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
-
- let mut bytes = Bytes::new(bytes);
- let timestamp = decode::read_u64(&mut bytes).map_err(error_report)?;
- let duration = decode::read_int(&mut bytes).map_err(error_report)?;
- let exit = decode::read_int(&mut bytes).map_err(error_report)?;
-
- let bytes = bytes.remaining_slice();
- let (command, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
- let (cwd, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
- let (session, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
- let (hostname, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
-
- let mut bytes = Bytes::new(bytes);
-
- let (deleted_at, bytes) = match decode::read_u64(&mut bytes) {
- Ok(unix) => (Some(unix), bytes.remaining_slice()),
- // we accept null here
- Err(ValueReadError::TypeMismatch(Marker::Null)) => (None, bytes.remaining_slice()),
- Err(err) => return Err(error_report(err)),
- };
- if !bytes.is_empty() {
- bail!("trailing bytes in encoded history. malformed")
- }
-
- Ok(Self {
- id: id.to_owned().into(),
- timestamp: OffsetDateTime::from_unix_timestamp_nanos(i128::from(timestamp))?,
- duration,
- exit,
- command: command.to_owned(),
- cwd: cwd.to_owned(),
- session: session.to_owned(),
- hostname: hostname.to_owned(),
- author: Self::author_from_hostname(hostname),
- intent: None,
- deleted_at: deleted_at
- .map(|t| OffsetDateTime::from_unix_timestamp_nanos(i128::from(t)))
- .transpose()?,
- })
- }
-
- fn deserialize_v1(bytes: &[u8]) -> Result<Self> {
- use rmp::decode;
-
- fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {
- eyre!("{err:?}")
- }
-
- let mut bytes = Bytes::new(bytes);
-
- let version = decode::read_u16(&mut bytes).map_err(error_report)?;
-
- if version != HISTORY_RECORD_VERSION_V1 {
- bail!("expected decoding v1 record, found v{version}");
- }
-
- let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?;
-
- if !(10..=11).contains(&nfields) {
- bail!("cannot decrypt history from a different version of Atuin");
- }
-
- let bytes = bytes.remaining_slice();
- let (id, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
-
- let mut bytes = Bytes::new(bytes);
- let timestamp = decode::read_u64(&mut bytes).map_err(error_report)?;
- let duration = decode::read_int(&mut bytes).map_err(error_report)?;
- let exit = decode::read_int(&mut bytes).map_err(error_report)?;
-
- let bytes = bytes.remaining_slice();
- let (command, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
- let (cwd, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
- let (session, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
- let (hostname, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
-
- let mut bytes = Bytes::new(bytes);
-
- let (deleted_at, bytes) = match decode::read_u64(&mut bytes) {
- Ok(unix) => (Some(unix), bytes.remaining_slice()),
- // we accept null here
- Err(ValueReadError::TypeMismatch(Marker::Null)) => (None, bytes.remaining_slice()),
- Err(err) => return Err(error_report(err)),
- };
- let (author, bytes) = Self::read_optional_string(bytes)?;
- let (intent, bytes) = if nfields > 10 {
- Self::read_optional_string(bytes)?
- } else {
- (None, bytes)
- };
-
- if !bytes.is_empty() {
- bail!("trailing bytes in encoded history. malformed")
- }
-
- Ok(Self {
- id: id.to_owned().into(),
- timestamp: OffsetDateTime::from_unix_timestamp_nanos(i128::from(timestamp))?,
- duration,
- exit,
- command: command.to_owned(),
- cwd: cwd.to_owned(),
- session: session.to_owned(),
- hostname: hostname.to_owned(),
- author: author.unwrap_or_else(|| Self::author_from_hostname(hostname)),
- intent,
- deleted_at: deleted_at
- .map(|t| OffsetDateTime::from_unix_timestamp_nanos(i128::from(t)))
- .transpose()?,
- })
- }
-
- pub(crate) fn deserialize(bytes: &[u8], version: &str) -> Result<Self> {
- match version {
- HISTORY_VERSION_V0 => Self::deserialize_v0(bytes),
- HISTORY_VERSION_V1 => Self::deserialize_v1(bytes),
-
- _ => bail!("unknown version {version:?}"),
- }
- }
-
- /// 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::atuin_client::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::atuin_client::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(crate) 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::atuin_client::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::atuin_client::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::atuin_client::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 {
- self.exit == 0 || self.duration == -1
- }
-
- pub(crate) fn should_save(&self, settings: &Settings) -> bool {
- !(self.command.is_empty()
- || settings.history_filter.is_match(&self.command)
- || settings.cwd_filter.is_match(&self.cwd)
- || (settings.secrets_filter && SECRET_PATTERNS_RE.is_match(&self.command)))
- }
-}
-
-#[cfg(test)]
-mod tests {
- use regex::RegexSet;
- use time::macros::datetime;
-
- use crate::atuin_client::{history::HISTORY_VERSION, settings::Settings};
-
- 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 {
- id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
- timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
- duration: 49_206_000,
- exit: 0,
- command: "git status".to_owned(),
- cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
- session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
- hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
- author: "conrad.ludgate".to_owned(),
- intent: None,
- deleted_at: None,
- };
-
- let serialized = history.serialize().expect("failed to serialize history");
- assert_eq!(
- &serialized.0[0..3],
- [205, 0, 1],
- "should encode as history v1"
- );
-
- let deserialized = History::deserialize(&serialized.0, HISTORY_VERSION)
- .expect("failed to deserialize history");
- assert_eq!(history, deserialized);
- }
-
- #[test]
- fn test_serialize_deserialize_deleted() {
- let history = History {
- id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
- timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
- duration: 49_206_000,
- exit: 0,
- command: "git status".to_owned(),
- cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
- session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
- hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
- author: "conrad.ludgate".to_owned(),
- intent: None,
- deleted_at: Some(datetime!(2023-11-19 20:18 +00:00)),
- };
-
- let serialized = history.serialize().expect("failed to serialize history");
-
- let deserialized = History::deserialize(&serialized.0, HISTORY_VERSION)
- .expect("failed to deserialize history");
-
- assert_eq!(history, deserialized);
- }
-
- #[test]
- fn test_serialize_deserialize_with_author_and_intent() {
- let history = History {
- id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
- timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
- duration: 49_206_000,
- exit: 0,
- command: "git status".to_owned(),
- cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
- session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
- hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
- author: "claude".to_owned(),
- intent: Some("check repository status".to_owned()),
- deleted_at: None,
- };
-
- let serialized = history.serialize().expect("failed to serialize history");
- let deserialized = History::deserialize(&serialized.0, HISTORY_VERSION)
- .expect("failed to deserialize history");
-
- assert_eq!(history, deserialized);
- }
-
- #[test]
- fn test_serialize_deserialize_version() {
- // v0
- let bytes_v0 = [
- 205, 0, 0, 153, 217, 32, 54, 54, 100, 49, 54, 99, 98, 101, 101, 55, 99, 100, 52, 55,
- 53, 51, 56, 101, 53, 99, 53, 98, 56, 98, 52, 52, 101, 57, 48, 48, 54, 101, 207, 23, 99,
- 98, 117, 24, 210, 246, 128, 206, 2, 238, 210, 240, 0, 170, 103, 105, 116, 32, 115, 116,
- 97, 116, 117, 115, 217, 42, 47, 85, 115, 101, 114, 115, 47, 99, 111, 110, 114, 97, 100,
- 46, 108, 117, 100, 103, 97, 116, 101, 47, 68, 111, 99, 117, 109, 101, 110, 116, 115,
- 47, 99, 111, 100, 101, 47, 97, 116, 117, 105, 110, 217, 32, 98, 57, 55, 100, 57, 97,
- 51, 48, 54, 102, 50, 55, 52, 52, 55, 51, 97, 50, 48, 51, 100, 50, 101, 98, 97, 52, 49,
- 102, 57, 52, 53, 55, 187, 102, 118, 102, 103, 57, 51, 54, 99, 48, 107, 112, 102, 58,
- 99, 111, 110, 114, 97, 100, 46, 108, 117, 100, 103, 97, 116, 101, 192,
- ];
-
- let deserialized = History::deserialize(&bytes_v0, "v0");
- assert!(deserialized.is_ok());
-
- let deserialized = History::deserialize(&bytes_v0, HISTORY_VERSION);
- assert!(deserialized.is_err());
-
- let current = History {
- id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
- timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
- duration: 49_206_000,
- exit: 0,
- command: "git status".to_owned(),
- cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
- session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
- hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
- author: "conrad.ludgate".to_owned(),
- intent: None,
- deleted_at: None,
- };
-
- let bytes_v1 = current.serialize().expect("failed to serialize history");
- let deserialized = History::deserialize(&bytes_v1.0, HISTORY_VERSION);
- assert!(deserialized.is_ok());
-
- let deserialized = History::deserialize(&bytes_v1.0, "v0");
- assert!(deserialized.is_err());
- }
-}
diff --git a/crates/client/src/atuin_client/history/builder.rs b/crates/client/src/atuin_client/history/builder.rs
deleted file mode 100644
index daa4ef49..00000000
--- a/crates/client/src/atuin_client/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(crate) 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/client/src/atuin_client/history/store.rs b/crates/client/src/atuin_client/history/store.rs
deleted file mode 100644
index 9c7771cc..00000000
--- a/crates/client/src/atuin_client/history/store.rs
+++ /dev/null
@@ -1,437 +0,0 @@
-use std::{collections::HashSet, fmt::Write, time::Duration};
-
-use eyre::{Result, bail, eyre};
-use indicatif::{ProgressBar, ProgressState, ProgressStyle};
-use rmp::decode::Bytes;
-use tracing::debug;
-
-use crate::atuin_client::{
- database::{ClientSqlite, current_context},
- record::{encryption::PASETO_V4, sqlite_store::SqliteStore},
-};
-use crate::atuin_common::record::{DecryptedData, Host, HostId, Record, RecordId, RecordIdx};
-
-use super::{HISTORY_TAG, HISTORY_VERSION, HISTORY_VERSION_V0, History, HistoryId};
-
-#[derive(Debug, Clone)]
-pub(crate) struct HistoryStore {
- pub(crate) store: SqliteStore,
- pub(crate) host_id: HostId,
- pub(crate) encryption_key: [u8; 32],
-}
-
-#[derive(Debug, Eq, PartialEq, Clone)]
-pub(crate) enum HistoryRecord {
- Create(History), // Create a history record
- Delete(HistoryId), // Delete a history record, identified by ID
-}
-
-impl HistoryRecord {
- /// Serialize a history record, returning `DecryptedData`
- /// The record will be of a certain type
- /// We map those like so:
- ///
- /// `HistoryRecord::Create` -> 0
- /// `HistoryRecord::Delete`-> 1
- ///
- /// This numeric identifier is then written as the first byte to the buffer. For history, we
- /// append the serialized history right afterwards, to avoid having to handle serialization
- /// twice.
- ///
- /// Deletion simply refers to the history by ID
- pub(crate) fn serialize(&self) -> Result<DecryptedData> {
- // probably don't actually need to use rmp here, but if we ever need to extend it, it's a
- // nice wrapper around raw byte stuff
- use rmp::encode;
-
- let mut output = vec![];
-
- match self {
- Self::Create(history) => {
- // 0 -> a history create
- encode::write_u8(&mut output, 0)?;
-
- let bytes = history.serialize()?;
-
- encode::write_bin(&mut output, &bytes.0)?;
- }
- Self::Delete(id) => {
- // 1 -> a history delete
- encode::write_u8(&mut output, 1)?;
- encode::write_str(&mut output, id.0.as_str())?;
- }
- }
-
- Ok(DecryptedData(output))
- }
-
- pub(crate) fn deserialize(bytes: &DecryptedData, version: &str) -> Result<Self> {
- use rmp::decode;
-
- fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {
- eyre!("{err:?}")
- }
-
- let mut bytes = Bytes::new(&bytes.0);
-
- let record_type = decode::read_u8(&mut bytes).map_err(error_report)?;
-
- match record_type {
- // 0 -> HistoryRecord::Create
- 0 => {
- // not super useful to us atm, but perhaps in the future
- // written by write_bin above
- decode::read_bin_len(&mut bytes).map_err(error_report)?;
-
- let record = History::deserialize(bytes.remaining_slice(), version)?;
-
- Ok(Self::Create(record))
- }
-
- // 1 -> HistoryRecord::Delete
- 1 => {
- let bytes = bytes.remaining_slice();
- let (id, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
-
- if !bytes.is_empty() {
- bail!(
- "trailing bytes decoding HistoryRecord::Delete - malformed? got {bytes:?}"
- );
- }
-
- Ok(Self::Delete(id.to_string().into()))
- }
-
- n => {
- bail!("unknown HistoryRecord type {n}")
- }
- }
- }
-}
-
-impl HistoryStore {
- pub(crate) fn new(store: SqliteStore, host_id: HostId, encryption_key: [u8; 32]) -> Self {
- Self {
- store,
- host_id,
- encryption_key,
- }
- }
-
- async fn push_record(&self, record: HistoryRecord) -> Result<(RecordId, RecordIdx)> {
- let bytes = record.serialize()?;
- let idx = self
- .store
- .last(self.host_id, HISTORY_TAG)
- .await?
- .map_or(0, |p| p.idx + 1);
-
- let record = Record::builder()
- .host(Host::new(self.host_id))
- .version(HISTORY_VERSION.to_string())
- .tag(HISTORY_TAG.to_string())
- .idx(idx)
- .data(bytes)
- .build();
-
- let id = record.id;
-
- self.store
- .push(&record.encrypt::<PASETO_V4>(&self.encryption_key))
- .await?;
-
- Ok((id, idx))
- }
-
- async fn push_batch(&self, records: impl Iterator<Item = HistoryRecord>) -> Result<()> {
- let mut ret = Vec::new();
-
- let idx = self
- .store
- .last(self.host_id, HISTORY_TAG)
- .await?
- .map_or(0, |p| p.idx + 1);
-
- // Could probably _also_ do this as an iterator, but let's see how this is for now.
- // optimizing for minimal sqlite transactions, this code can be optimised later
- for (n, record) in records.enumerate() {
- let bytes = record.serialize()?;
-
- let record = Record::builder()
- .host(Host::new(self.host_id))
- .version(HISTORY_VERSION.to_string())
- .tag(HISTORY_TAG.to_string())
- .idx(idx + n as u64)
- .data(bytes)
- .build();
-
- let record = record.encrypt::<PASETO_V4>(&self.encryption_key);
-
- ret.push(record);
- }
-
- self.store.push_batch(ret.iter()).await?;
-
- Ok(())
- }
-
- pub(crate) async fn delete(&self, id: HistoryId) -> Result<(RecordId, RecordIdx)> {
- let record = HistoryRecord::Delete(id);
-
- self.push_record(record).await
- }
-
- /// Delete a batch of history entries via the record store.
- /// Returns the record IDs so the caller can run `incremental_build` when ready.
- pub(crate) async fn delete_entries(
- &self,
- entries: impl IntoIterator<Item = History>,
- ) -> Result<Vec<RecordId>> {
- let mut record_ids = Vec::new();
- for entry in entries {
- let (id, _) = self.delete(entry.id).await?;
- record_ids.push(id);
- }
- Ok(record_ids)
- }
-
- pub(crate) async fn push(&self, history: History) -> Result<(RecordId, RecordIdx)> {
- // TODO(ellie): move the history store to its own file
- // it's tiny rn so fine as is
- let record = HistoryRecord::Create(history);
-
- self.push_record(record).await
- }
-
- pub(crate) async fn history(&self) -> Result<Vec<HistoryRecord>> {
- // Atm this loads all history into memory
- // Not ideal as that is potentially quite a lot, although history will be small.
- let records = self.store.all_tagged(HISTORY_TAG).await?;
- let mut ret = Vec::with_capacity(records.len());
-
- for record in records {
- let hist = match record.version.as_str() {
- HISTORY_VERSION_V0 | HISTORY_VERSION => {
- let version = record.version.clone();
- let decrypted = record.decrypt::<PASETO_V4>(&self.encryption_key)?;
-
- HistoryRecord::deserialize(&decrypted.data, version.as_str())
- }
- version => bail!("unknown history version {version:?}"),
- }?;
-
- ret.push(hist);
- }
-
- Ok(ret)
- }
-
- pub(crate) async fn build(&self, database: &ClientSqlite) -> Result<()> {
- // I'd like to change how we rebuild and not couple this with the database, but need to
- // consider the structure more deeply. This will be easy to change.
-
- // TODO(ellie): page or iterate this
- let history = self.history().await?;
-
- // In theory we could flatten this here
- // The current issue is that the database may have history in it already, from the old sync
- // This didn't actually delete old history
- // If we're sure we have a DB only maintained by the new store, we can flatten
- // create/delete before we even get to sqlite
- let mut creates = Vec::new();
- let mut deletes = Vec::new();
-
- for i in history {
- match i {
- HistoryRecord::Create(h) => {
- creates.push(h);
- }
- HistoryRecord::Delete(id) => {
- deletes.push(id);
- }
- }
- }
-
- database.save_bulk(&creates).await?;
- database.delete_rows(&deletes).await?;
-
- Ok(())
- }
-
- pub(crate) async fn incremental_build(
- &self,
- database: &ClientSqlite,
- ids: &[RecordId],
- ) -> Result<()> {
- for id in ids {
- let record = self.store.get(*id).await;
-
- if let Ok(record) = record {
- if record.tag != HISTORY_TAG {
- continue;
- }
-
- let version = record.version.clone();
- let decrypted = record.decrypt::<PASETO_V4>(&self.encryption_key)?;
- let record = match version.as_str() {
- HISTORY_VERSION_V0 | HISTORY_VERSION => {
- HistoryRecord::deserialize(&decrypted.data, version.as_str())?
- }
- version => bail!("unknown history version {version:?}"),
- };
-
- match record {
- HistoryRecord::Create(h) => {
- // TODO: benchmark CPU time/memory tradeoff of batch commit vs one at a time
- database.save(&h).await?;
- }
- HistoryRecord::Delete(id) => {
- database.delete_rows(&[id]).await?;
- }
- }
- }
- }
-
- Ok(())
- }
-
- /// Get a list of history IDs that exist in the store
- /// Note: This currently involves loading all history into memory. This is not going to be a
- /// large amount in absolute terms, but do not all it in a hot loop.
- pub(crate) async fn history_ids(&self) -> Result<HashSet<HistoryId>> {
- let history = self.history().await?;
-
- let ret = history
- .iter()
- .map(|h| match h {
- HistoryRecord::Create(h) => h.id.clone(),
- HistoryRecord::Delete(id) => id.clone(),
- })
- .collect::<HashSet<_>>();
-
- Ok(ret)
- }
-
- pub(crate) async fn init_store(&self, db: &ClientSqlite) -> Result<()> {
- let pb = ProgressBar::new_spinner();
- pb.set_style(
- ProgressStyle::with_template("{spinner:.blue} {msg}")
- .unwrap()
- .with_key("eta", |state: &ProgressState, w: &mut dyn Write| {
- write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap();
- })
- .progress_chars("#>-"),
- );
- pb.enable_steady_tick(Duration::from_millis(500));
-
- pb.set_message("Fetching history from old database");
-
- let context = current_context().await?;
- let history = db.list(&[], &context, None, false, true).await?;
-
- pb.set_message("Fetching history already in store");
- let store_ids = self.history_ids().await?;
-
- pb.set_message("Converting old history to new store");
- let mut records = Vec::new();
-
- for i in history {
- debug!("loaded {}", i.id);
-
- if store_ids.contains(&i.id) {
- debug!("skipping {} - already exists", i.id);
- continue;
- }
-
- if i.deleted_at.is_some() {
- records.push(HistoryRecord::Delete(i.id));
- } else {
- records.push(HistoryRecord::Create(i));
- }
- }
-
- pb.set_message("Writing to db");
-
- if !records.is_empty() {
- self.push_batch(records.into_iter()).await?;
- }
-
- pb.finish_with_message("Import complete");
-
- Ok(())
- }
-}
-
-#[cfg(test)]
-mod tests {
- use crate::atuin_common::record::DecryptedData;
- use time::macros::datetime;
-
- use crate::atuin_client::history::{HISTORY_VERSION, store::HistoryRecord};
-
- use super::History;
-
- #[test]
- fn test_serialize_deserialize_create() {
- let bytes = [
- 204, 0, 196, 147, 205, 0, 1, 154, 217, 32, 48, 49, 56, 99, 100, 52, 102, 101, 56, 49,
- 55, 53, 55, 99, 100, 50, 97, 101, 101, 54, 53, 99, 100, 55, 56, 54, 49, 102, 57, 99,
- 56, 49, 207, 23, 166, 251, 212, 181, 82, 0, 0, 100, 0, 162, 108, 115, 217, 41, 47, 85,
- 115, 101, 114, 115, 47, 101, 108, 108, 105, 101, 47, 115, 114, 99, 47, 103, 105, 116,
- 104, 117, 98, 46, 99, 111, 109, 47, 97, 116, 117, 105, 110, 115, 104, 47, 97, 116, 117,
- 105, 110, 217, 32, 48, 49, 56, 99, 100, 52, 102, 101, 97, 100, 56, 57, 55, 53, 57, 55,
- 56, 53, 50, 53, 50, 55, 97, 51, 49, 99, 57, 57, 56, 48, 53, 57, 170, 98, 111, 111, 112,
- 58, 101, 108, 108, 105, 101, 192, 165, 101, 108, 108, 105, 101,
- ];
-
- let history = History {
- id: "018cd4fe81757cd2aee65cd7861f9c81".to_owned().into(),
- timestamp: datetime!(2024-01-04 00:00:00.000000 +00:00),
- duration: 100,
- exit: 0,
- command: "ls".to_owned(),
- cwd: "/Users/ellie/src/github.com/atuinsh/atuin".to_owned(),
- session: "018cd4fead897597852527a31c998059".to_owned(),
- hostname: "boop:ellie".to_owned(),
- author: "ellie".to_owned(),
- intent: None,
- deleted_at: None,
- };
-
- let record = HistoryRecord::Create(history);
-
- let serialized = record.serialize().expect("failed to serialize history");
- assert_eq!(serialized.0, bytes);
-
- let deserialized = HistoryRecord::deserialize(&serialized, HISTORY_VERSION)
- .expect("failed to deserialize HistoryRecord");
- assert_eq!(deserialized, record);
-
- // check the snapshot too
- let deserialized =
- HistoryRecord::deserialize(&DecryptedData(Vec::from(bytes)), HISTORY_VERSION)
- .expect("failed to deserialize HistoryRecord");
- assert_eq!(deserialized, record);
- }
-
- #[test]
- fn test_serialize_deserialize_delete() {
- let bytes = [
- 204, 1, 217, 32, 48, 49, 56, 99, 100, 52, 102, 101, 56, 49, 55, 53, 55, 99, 100, 50,
- 97, 101, 101, 54, 53, 99, 100, 55, 56, 54, 49, 102, 57, 99, 56, 49,
- ];
- let record = HistoryRecord::Delete("018cd4fe81757cd2aee65cd7861f9c81".to_string().into());
-
- let serialized = record.serialize().expect("failed to serialize history");
- assert_eq!(serialized.0, bytes);
-
- let deserialized = HistoryRecord::deserialize(&serialized, HISTORY_VERSION)
- .expect("failed to deserialize HistoryRecord");
- assert_eq!(deserialized, record);
-
- let deserialized =
- HistoryRecord::deserialize(&DecryptedData(Vec::from(bytes)), HISTORY_VERSION)
- .expect("failed to deserialize HistoryRecord");
- assert_eq!(deserialized, record);
- }
-}
diff --git a/crates/client/src/atuin_client/meta.rs b/crates/client/src/atuin_client/meta.rs
deleted file mode 100644
index 079c9926..00000000
--- a/crates/client/src/atuin_client/meta.rs
+++ /dev/null
@@ -1,182 +0,0 @@
-use std::path::Path;
-use std::str::FromStr;
-use std::time::Duration;
-
-use crate::atuin_common::record::HostId;
-use eyre::{Result, eyre};
-use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions};
-use time::{OffsetDateTime, format_description::well_known::Rfc3339};
-use tokio::sync::OnceCell;
-use tracing::debug;
-use uuid::Uuid;
-
-const KEY_HOST_ID: &str = "host_id";
-const KEY_LAST_SYNC: &str = "last_sync_time";
-
-pub(crate) struct MetaStore {
- pool: SqlitePool,
- cached_host_id: OnceCell<HostId>,
-}
-
-impl MetaStore {
- pub(crate) async fn new(path: impl AsRef<Path>, timeout: f64) -> Result<Self> {
- let path = path.as_ref();
- let path_str = path
- .as_os_str()
- .to_str()
- .ok_or_else(|| eyre!("meta database path is not valid UTF-8: {path:?}"))?;
- debug!("opening meta sqlite database at {path:?}");
-
- let is_memory = path_str.contains(":memory:");
-
- if !is_memory
- && !path.exists()
- && let Some(dir) = path.parent()
- {
- fs_err::create_dir_all(dir)?;
- }
-
- // Use DELETE journal mode instead of WAL. This is a small, infrequently-
- // written KV store — WAL's concurrency benefits aren't needed, and DELETE
- // mode avoids creating auxiliary -wal/-shm files that complicate
- // permission handling.
- let opts = SqliteConnectOptions::from_str(path_str)?
- .journal_mode(SqliteJournalMode::Delete)
- .optimize_on_close(true, None)
- .create_if_missing(true);
-
- let pool = SqlitePoolOptions::new()
- .acquire_timeout(Duration::from_secs_f64(timeout))
- .connect_with(opts)
- .await?;
-
- sqlx::migrate!("./db/client-meta-migrations")
- .run(&pool)
- .await?;
-
- // Session tokens are stored in this database, so restrict permissions.
- #[cfg(unix)]
- if !is_memory {
- use std::os::unix::fs::PermissionsExt;
- std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
- }
-
- let store = Self {
- pool,
- cached_host_id: OnceCell::const_new(),
- };
-
- Ok(store)
- }
-
- // Generic key-value operations
-
- pub(crate) async fn get(&self, key: &str) -> Result<Option<String>> {
- let row: Option<(String,)> = sqlx::query_as("SELECT value FROM meta WHERE key = ?1")
- .bind(key)
- .fetch_optional(&self.pool)
- .await?;
-
- Ok(row.map(|r| r.0))
- }
-
- pub(crate) async fn set(&self, key: &str, value: &str) -> Result<()> {
- sqlx::query(
- "
- INSERT INTO meta (key, value, updated_at)
- VALUES (?1, ?2, strftime('%s', 'now'))
- ON CONFLICT(key) DO UPDATE
- SET value = ?2, updated_at = strftime('%s', 'now')
- ",
- )
- .bind(key)
- .bind(value)
- .execute(&self.pool)
- .await?;
-
- Ok(())
- }
-
- // Typed accessors
-
- pub(crate) async fn host_id(&self) -> Result<HostId> {
- self.cached_host_id
- .get_or_try_init(|| async {
- if let Some(id) = self.get(KEY_HOST_ID).await? {
- let parsed = Uuid::from_str(id.as_str())
- .map_err(|e| eyre!("failed to parse host ID: {e}"))?;
- return Ok(HostId(parsed));
- }
-
- let uuid = crate::atuin_common::utils::uuid_v7();
- self.set(KEY_HOST_ID, uuid.as_simple().to_string().as_ref())
- .await?;
-
- Ok(HostId(uuid))
- })
- .await
- .copied()
- }
-
- pub(crate) async fn last_sync(&self) -> Result<OffsetDateTime> {
- match self.get(KEY_LAST_SYNC).await? {
- Some(v) => Ok(OffsetDateTime::parse(v.as_str(), &Rfc3339)?),
- None => Ok(OffsetDateTime::UNIX_EPOCH),
- }
- }
-
- pub(crate) async fn save_sync_time(&self) -> Result<()> {
- self.set(
- KEY_LAST_SYNC,
- OffsetDateTime::now_utc().format(&Rfc3339)?.as_str(),
- )
- .await
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::{MetaStore, OffsetDateTime};
-
- async fn new_test_store() -> MetaStore {
- MetaStore::new("sqlite::memory:", 2.0).await.unwrap()
- }
-
- #[tokio::test]
- async fn test_get_set_delete() {
- let store = new_test_store().await;
-
- assert_eq!(store.get("foo").await.unwrap(), None);
-
- store.set("foo", "bar").await.unwrap();
- assert_eq!(store.get("foo").await.unwrap(), Some("bar".to_string()));
-
- store.set("foo", "baz").await.unwrap();
- assert_eq!(store.get("foo").await.unwrap(), Some("baz".to_string()));
-
- store.delete("foo").await.unwrap();
- assert_eq!(store.get("foo").await.unwrap(), None);
- }
-
- #[tokio::test]
- async fn test_host_id_generation_and_stability() {
- let store = new_test_store().await;
-
- let id1 = store.host_id().await.unwrap();
- let id2 = store.host_id().await.unwrap();
-
- assert_eq!(id1, id2, "host_id should be stable across calls");
- }
-
- #[tokio::test]
- async fn test_sync_time() {
- let store = new_test_store().await;
-
- let t = store.last_sync().await.unwrap();
- assert_eq!(t, OffsetDateTime::UNIX_EPOCH);
-
- store.save_sync_time().await.unwrap();
- let t = store.last_sync().await.unwrap();
- assert!(t > OffsetDateTime::UNIX_EPOCH);
- }
-}
diff --git a/crates/client/src/atuin_client/mod.rs b/crates/client/src/atuin_client/mod.rs
index 851dfbdb..0678d8b7 100644
--- a/crates/client/src/atuin_client/mod.rs
+++ b/crates/client/src/atuin_client/mod.rs
@@ -1,12 +1,2 @@
-pub(crate) mod api_client;
-pub(crate) mod database;
-pub(crate) mod encryption;
-pub(crate) mod history;
-pub(crate) mod meta;
-pub(crate) mod ordering;
-pub(crate) mod record;
-pub(crate) mod secrets;
pub(crate) mod settings;
pub(crate) mod theme;
-
-mod utils;
diff --git a/crates/client/src/atuin_client/ordering.rs b/crates/client/src/atuin_client/ordering.rs
deleted file mode 100644
index 84001f52..00000000
--- a/crates/client/src/atuin_client/ordering.rs
+++ /dev/null
@@ -1,31 +0,0 @@
-use minspan::minspan;
-
-use super::{history::History, settings::SearchMode};
-
-pub(crate) fn reorder_fuzzy(mode: SearchMode, query: &str, res: Vec<History>) -> Vec<History> {
- match mode {
- SearchMode::Fuzzy => reorder(query, |x| &x.command, res),
- _ => res,
- }
-}
-
-#[expect(clippy::needless_pass_by_value, reason = "makes things easier")]
-fn reorder<F, A>(query: &str, f: F, res: Vec<A>) -> Vec<A>
-where
- F: Fn(&A) -> &String,
- A: Clone,
-{
- let mut r = res.clone();
- let qvec = &query.chars().collect();
- r.sort_by_cached_key(|h| {
- // TODO for fzf search we should sum up scores for each matched term
- //
- // The fallback is a little unfortunate: when we are asked to match a query that is found nowhere,
- // we don't want to return a None, as the comparison behaviour would put the worst matches
- // at the front. Therefore, we'll return a set of indices that are one larger than the longest
- // possible legitimate match. This is meaningless except as a comparison.
- let (from, to) = minspan::span(qvec, &(f(h).chars().collect())).unwrap_or((0, res.len()));
- 1 + to - from
- });
- r
-}
diff --git a/crates/client/src/atuin_client/record/encryption.rs b/crates/client/src/atuin_client/record/encryption.rs
deleted file mode 100644
index d8587cf6..00000000
--- a/crates/client/src/atuin_client/record/encryption.rs
+++ /dev/null
@@ -1,379 +0,0 @@
-use crate::atuin_common::record::{
- AdditionalData, DecryptedData, EncryptedData, Encryption, HostId, RecordId, RecordIdx,
-};
-use base64::{Engine, engine::general_purpose};
-use eyre::{Context, Result, ensure};
-use rusty_paserk::{Key, KeyId, Local, PieWrappedKey};
-use rusty_paseto::core::{
- ImplicitAssertion, Key as DataKey, Local as LocalPurpose, Paseto, PasetoNonce, Payload, V4,
-};
-use serde::{Deserialize, Serialize};
-
-/// Use PASETO V4 Local encryption using the additional data as an implicit assertion.
-#[expect(non_camel_case_types)]
-pub(crate) struct PASETO_V4;
-
-/*
-Why do we use a random content-encryption key?
-Originally I was planning on using a derived key for encryption based on additional data.
-This would be a lot more secure than using the master key directly.
-
-However, there's an established norm of using a random key. This scheme might be otherwise known as
-- client-side encryption
-- envelope encryption
-- key wrapping
-
-A HSM (Hardware Security Module) provider, eg: AWS, Azure, GCP, or even a physical device like a YubiKey
-will have some keys that they keep to themselves. These keys never leave their physical hardware.
-If they never leave the hardware, then encrypting large amounts of data means giving them the data and waiting.
-This is not a practical solution. Instead, generate a unique key for your data, encrypt that using your HSM
-and then store that with your data.
-
-See
- - <https://docs.aws.amazon.com/wellarchitected/latest/financial-services-industry-lens/use-envelope-encryption-with-customer-master-keys.html>
- - <https://cloud.google.com/kms/docs/envelope-encryption>
- - <https://learn.microsoft.com/en-us/azure/storage/blobs/client-side-encryption?tabs=dotnet#encryption-and-decryption-via-the-envelope-technique>
- - <https://www.yubico.com/gb/product/yubihsm-2-fips/>
- - <https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html#encrypting-stored-keys>
-
-Why would we care? In the past we have received some requests for company solutions. If in future we can configure a
-KMS service with little effort, then that would solve a lot of issues for their security team.
-
-Even for personal use, if a user is not comfortable with sharing keys between hosts,
-GCP HSM costs $1/month and $0.03 per 10,000 key operations. Assuming an active user runs
-1000 atuin records a day, that would only cost them $1 and 10 cent a month.
-
-Additionally, key rotations are much simpler using this scheme. Rotating a key is as simple as re-encrypting the CEK, and not the message contents.
-This makes it very fast to rotate a key in bulk.
-
-For future reference, with asymmetric encryption, you can encrypt the CEK without the HSM's involvement, but decrypting
-will need the HSM. This allows the encryption path to still be extremely fast (no network calls) but downloads/decryption
-that happens in the background can make the network calls to the HSM
-*/
-
-impl Encryption for PASETO_V4 {
- fn re_encrypt(
- mut data: EncryptedData,
- _ad: AdditionalData<'_>,
- old_key: &[u8; 32],
- new_key: &[u8; 32],
- ) -> Result<EncryptedData> {
- let cek = Self::decrypt_cek(&data.content_encryption_key, old_key)?;
- data.content_encryption_key = Self::encrypt_cek(cek, new_key);
- Ok(data)
- }
-
- fn encrypt(data: DecryptedData, ad: AdditionalData<'_>, key: &[u8; 32]) -> EncryptedData {
- // generate a random key for this entry
- // aka content-encryption-key (CEK)
- let random_key = Key::<V4, Local>::new_os_random();
-
- let assertions = Assertions::from(ad).encode();
-
- // build the payload and encrypt the token
- let payload = serde_json::to_string(&AtuinPayload {
- data: general_purpose::URL_SAFE_NO_PAD.encode(data.0),
- })
- .expect("json encoding can't fail");
- let nonce = DataKey::<32>::try_new_random().expect("could not source from random");
- let nonce = PasetoNonce::<V4, LocalPurpose>::from(&nonce);
-
- let token = Paseto::<V4, LocalPurpose>::builder()
- .set_payload(Payload::from(payload.as_str()))
- .set_implicit_assertion(ImplicitAssertion::from(assertions.as_str()))
- .try_encrypt(&random_key.into(), &nonce)
- .expect("error encrypting atuin data");
-
- EncryptedData {
- data: token,
- content_encryption_key: Self::encrypt_cek(random_key, key),
- }
- }
-
- fn decrypt(
- data: EncryptedData,
- ad: AdditionalData<'_>,
- key: &[u8; 32],
- ) -> Result<DecryptedData> {
- let token = data.data;
- let cek = Self::decrypt_cek(&data.content_encryption_key, key)?;
-
- // encode the implicit assertions
- let assertions = Assertions::from(ad).encode();
-
- // decrypt the payload with the footer and implicit assertions
- let payload = Paseto::<V4, LocalPurpose>::try_decrypt(
- &token,
- &cek.into(),
- None,
- ImplicitAssertion::from(&*assertions),
- )
- .context("could not decrypt entry")?;
-
- let payload: AtuinPayload = serde_json::from_str(&payload)?;
- let data = general_purpose::URL_SAFE_NO_PAD.decode(payload.data)?;
- Ok(DecryptedData(data))
- }
-}
-
-impl PASETO_V4 {
- fn decrypt_cek(wrapped_cek: &str, key: &[u8; 32]) -> Result<Key<V4, Local>> {
- let wrapping_key = Key::<V4, Local>::from_bytes(*key);
-
- // let wrapping_key = PasetoSymmetricKey::from(Key::from(key));
-
- let AtuinFooter { kid, wpk } = serde_json::from_str(wrapped_cek)
- .context("wrapped cek did not contain the correct contents")?;
-
- // check that the wrapping key matches the required key to decrypt.
- // In future, we could support multiple keys and use this key to
- // look up the key rather than only allow one key.
- // For now though we will only support the one key and key rotation will
- // have to be a hard reset
- let current_kid = wrapping_key.to_id();
-
- ensure!(
- current_kid == kid,
- "attempting to decrypt with incorrect key. currently using {current_kid}, expecting {kid}"
- );
-
- // decrypt the random key
- Ok(wpk.unwrap_key(&wrapping_key)?)
- }
-
- fn encrypt_cek(cek: Key<V4, Local>, key: &[u8; 32]) -> String {
- // aka key-encryption-key (KEK)
- let wrapping_key = Key::<V4, Local>::from_bytes(*key);
- let kid = wrapping_key.to_id();
-
- // wrap the random key so we can decrypt it later
- let wrapped_cek = AtuinFooter {
- wpk: cek.wrap_pie(&wrapping_key),
- kid,
- };
- serde_json::to_string(&wrapped_cek).expect("could not serialize wrapped cek")
- }
-}
-
-#[derive(Serialize, Deserialize)]
-struct AtuinPayload {
- data: String,
-}
-
-#[derive(Serialize, Deserialize)]
-/// Well-known footer claims for decrypting. This is not encrypted but is stored in the record.
-/// <https://github.com/paseto-standard/paseto-spec/blob/master/docs/02-Implementation-Guide/04-Claims.md#optional-footer-claims>
-struct AtuinFooter {
- /// Wrapped key
- wpk: PieWrappedKey<V4, Local>,
- /// ID of the key which was used to wrap
- kid: KeyId<V4, Local>,
-}
-
-/// Used in the implicit assertions. This is not encrypted and not stored in the data blob.
-// This cannot be changed, otherwise it breaks the authenticated encryption.
-#[derive(Debug, Copy, Clone, Serialize)]
-struct Assertions<'a> {
- id: &'a RecordId,
- idx: &'a RecordIdx,
- version: &'a str,
- tag: &'a str,
- host: &'a HostId,
-}
-
-impl<'a> From<AdditionalData<'a>> for Assertions<'a> {
- fn from(ad: AdditionalData<'a>) -> Self {
- Self {
- id: ad.id,
- version: ad.version,
- tag: ad.tag,
- host: ad.host,
- idx: ad.idx,
- }
- }
-}
-
-impl Assertions<'_> {
- fn encode(&self) -> String {
- serde_json::to_string(self).expect("could not serialize implicit assertions")
- }
-}
-
-#[cfg(test)]
-mod tests {
- use crate::atuin_common::{
- record::{Host, Record},
- utils::uuid_v7,
- };
-
- use super::{
- AdditionalData, DecryptedData, Encryption, HostId, Key, Local, PASETO_V4, RecordId, V4,
- };
-
- #[test]
- fn round_trip() {
- let key = Key::<V4, Local>::new_os_random();
-
- let ad = AdditionalData {
- id: &RecordId(uuid_v7()),
- version: "v0",
- tag: "kv",
- host: &HostId(uuid_v7()),
- idx: &0,
- };
-
- let data = DecryptedData(vec![1, 2, 3, 4]);
-
- let encrypted = PASETO_V4::encrypt(data.clone(), ad, &key.to_bytes());
- let decrypted = PASETO_V4::decrypt(encrypted, ad, &key.to_bytes()).unwrap();
- assert_eq!(decrypted, data);
- }
-
- #[test]
- fn same_entry_different_output() {
- let key = Key::<V4, Local>::new_os_random();
-
- let ad = AdditionalData {
- id: &RecordId(uuid_v7()),
- version: "v0",
- tag: "kv",
- host: &HostId(uuid_v7()),
- idx: &0,
- };
-
- let data = DecryptedData(vec![1, 2, 3, 4]);
-
- let encrypted = PASETO_V4::encrypt(data.clone(), ad, &key.to_bytes());
- let encrypted2 = PASETO_V4::encrypt(data, ad, &key.to_bytes());
-
- assert_ne!(
- encrypted.data, encrypted2.data,
- "re-encrypting the same contents should have different output due to key randomization"
- );
- }
-
- #[test]
- fn cannot_decrypt_different_key() {
- let key = Key::<V4, Local>::new_os_random();
- let fake_key = Key::<V4, Local>::new_os_random();
-
- let ad = AdditionalData {
- id: &RecordId(uuid_v7()),
- version: "v0",
- tag: "kv",
- host: &HostId(uuid_v7()),
- idx: &0,
- };
-
- let data = DecryptedData(vec![1, 2, 3, 4]);
-
- let encrypted = PASETO_V4::encrypt(data, ad, &key.to_bytes());
- drop(PASETO_V4::decrypt(encrypted, ad, &fake_key.to_bytes()).unwrap_err());
- }
-
- #[test]
- fn cannot_decrypt_different_id() {
- let key = Key::<V4, Local>::new_os_random();
-
- let ad = AdditionalData {
- id: &RecordId(uuid_v7()),
- version: "v0",
- tag: "kv",
- host: &HostId(uuid_v7()),
- idx: &0,
- };
-
- let data = DecryptedData(vec![1, 2, 3, 4]);
-
- let encrypted = PASETO_V4::encrypt(data, ad, &key.to_bytes());
-
- let ad = AdditionalData {
- id: &RecordId(uuid_v7()),
- ..ad
- };
- drop(PASETO_V4::decrypt(encrypted, ad, &key.to_bytes()).unwrap_err());
- }
-
- #[test]
- fn re_encrypt_round_trip() {
- let key1 = Key::<V4, Local>::new_os_random();
- let key2 = Key::<V4, Local>::new_os_random();
-
- let ad = AdditionalData {
- id: &RecordId(uuid_v7()),
- version: "v0",
- tag: "kv",
- host: &HostId(uuid_v7()),
- idx: &0,
- };
-
- let data = DecryptedData(vec![1, 2, 3, 4]);
-
- let encrypted1 = PASETO_V4::encrypt(data.clone(), ad, &key1.to_bytes());
- let encrypted2 =
- PASETO_V4::re_encrypt(encrypted1.clone(), ad, &key1.to_bytes(), &key2.to_bytes())
- .unwrap();
-
- // we only re-encrypt the content keys
- assert_eq!(encrypted1.data, encrypted2.data);
- assert_ne!(
- encrypted1.content_encryption_key,
- encrypted2.content_encryption_key
- );
-
- let decrypted = PASETO_V4::decrypt(encrypted2, ad, &key2.to_bytes()).unwrap();
-
- assert_eq!(decrypted, data);
- }
-
- #[test]
- fn full_record_round_trip() {
- let key = [0x55; 32];
- let record = Record::builder()
- .id(RecordId(uuid_v7()))
- .version("v0".to_owned())
- .tag("kv".to_owned())
- .host(Host::new(HostId(uuid_v7())))
- .timestamp(1_687_244_806_000_000)
- .data(DecryptedData(vec![1, 2, 3, 4]))
- .idx(0)
- .build();
-
- let encrypted = record.encrypt::<PASETO_V4>(&key);
-
- assert!(!encrypted.data.data.is_empty());
- assert!(!encrypted.data.content_encryption_key.is_empty());
-
- let decrypted = encrypted.decrypt::<PASETO_V4>(&key).unwrap();
-
- assert_eq!(decrypted.data.0, [1, 2, 3, 4]);
- }
-
- #[test]
- fn full_record_round_trip_fail() {
- let key = [0x55; 32];
- let record = Record::builder()
- .id(RecordId(uuid_v7()))
- .version("v0".to_owned())
- .tag("kv".to_owned())
- .host(Host::new(HostId(uuid_v7())))
- .timestamp(1_687_244_806_000_000)
- .data(DecryptedData(vec![1, 2, 3, 4]))
- .idx(0)
- .build();
-
- let encrypted = record.encrypt::<PASETO_V4>(&key);
-
- let mut enc1 = encrypted.clone();
- enc1.host = Host::new(HostId(uuid_v7()));
- let _ = enc1
- .decrypt::<PASETO_V4>(&key)
- .expect_err("tampering with the host should result in auth failure");
-
- let mut enc2 = encrypted;
- enc2.id = RecordId(uuid_v7());
- let _ = enc2
- .decrypt::<PASETO_V4>(&key)
- .expect_err("tampering with the id should result in auth failure");
- }
-}
diff --git a/crates/client/src/atuin_client/record/mod.rs b/crates/client/src/atuin_client/record/mod.rs
deleted file mode 100644
index 4e5774ea..00000000
--- a/crates/client/src/atuin_client/record/mod.rs
+++ /dev/null
@@ -1,3 +0,0 @@
-pub(crate) mod encryption;
-pub(crate) mod sqlite_store;
-pub(crate) mod sync;
diff --git a/crates/client/src/atuin_client/record/sqlite_store.rs b/crates/client/src/atuin_client/record/sqlite_store.rs
deleted file mode 100644
index 18f5c869..00000000
--- a/crates/client/src/atuin_client/record/sqlite_store.rs
+++ /dev/null
@@ -1,563 +0,0 @@
-// Here we are using sqlite as a pretty dumb store, and will not be running any complex queries.
-// Multiple stores of multiple types are all stored in one chonky table (for now), and we just index
-// by tag/host
-
-use std::path::Path;
-use std::str::FromStr;
-
-use eyre::{Result, eyre};
-use fs_err as fs;
-
-use sqlx::{
- Row,
- sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqliteRow},
-};
-use tracing::debug;
-
-use crate::atuin_client::utils::setup_db;
-use crate::atuin_common::record::{
- EncryptedData, Host, HostId, Record, RecordId, RecordIdx, RecordStatus,
-};
-use crate::atuin_common::utils;
-use uuid::Uuid;
-
-use super::encryption::PASETO_V4;
-
-#[derive(Debug, Clone)]
-pub(crate) struct SqliteStore {
- pool: SqlitePool,
-}
-
-impl SqliteStore {
- pub(crate) async fn new(path: impl AsRef<Path>, timeout: f64) -> Result<Self> {
- fn mk_opts(path: &str) -> sqlx::Result<SqliteConnectOptions> {
- let opts = SqliteConnectOptions::from_str(path)?
- .journal_mode(SqliteJournalMode::Wal)
- .foreign_keys(true)
- .create_if_missing(true);
-
- Ok(opts)
- }
-
- let path = path.as_ref();
-
- debug!("opening sqlite database at {path:?}");
-
- if utils::broken_symlink(path) {
- eprintln!(
- "Atuin: Sqlite db path ({}) is a broken symlink. Unable to read or create replacement.",
- path.display()
- );
- std::process::exit(1);
- }
-
- if !path.exists()
- && let Some(dir) = path.parent()
- {
- fs::create_dir_all(dir)?;
- }
-
- let pool = setup_db!(path, timeout, mk_opts, "./db/client-record-migrations").await?;
-
- Ok(Self { pool })
- }
-
- async fn save_raw(
- tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
- r: &Record<EncryptedData>,
- ) -> Result<()> {
- // In sqlite, we are "limited" to i64. But that is still fine, until 2262.
- sqlx::query(
- "insert or ignore into store(id, idx, host, tag, timestamp, version, data, cek)
- values(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
- )
- .bind(r.id.0.as_hyphenated().to_string())
- .bind(r.idx as i64)
- .bind(r.host.id.0.as_hyphenated().to_string())
- .bind(r.tag.as_str())
- .bind(r.timestamp as i64)
- .bind(r.version.as_str())
- .bind(r.data.data.as_str())
- .bind(r.data.content_encryption_key.as_str())
- .execute(&mut **tx)
- .await?;
-
- Ok(())
- }
-
- #[expect(
- clippy::needless_pass_by_value,
- reason = "this is used in a place with fixed function signature"
- )]
- fn query_row(row: SqliteRow) -> Record<EncryptedData> {
- let idx: i64 = row.get("idx");
- let timestamp: i64 = row.get("timestamp");
-
- // tbh at this point things are pretty fucked so just panic
- let id = Uuid::from_str(row.get("id")).expect("invalid id UUID format in sqlite DB");
- let host = Uuid::from_str(row.get("host")).expect("invalid host UUID format in sqlite DB");
-
- Record {
- id: RecordId(id),
- idx: idx as u64,
- host: Host::new(HostId(host)),
- timestamp: timestamp as u64,
- tag: row.get("tag"),
- version: row.get("version"),
- data: EncryptedData {
- data: row.get("data"),
- content_encryption_key: row.get("cek"),
- },
- }
- }
-
- async fn load_all(&self) -> Result<Vec<Record<EncryptedData>>> {
- let res = sqlx::query("select * from store ")
- .map(Self::query_row)
- .fetch_all(&self.pool)
- .await?;
-
- Ok(res)
- }
-}
-
-/// A record store stores records
-/// In more detail - we tend to need to process this into _another_ format to actually query it.
-/// As is, the record store is intended as the source of truth for arbitrary data, which could
-/// be shell history, kvs, etc.
-impl SqliteStore {
- /// Push a record
- pub(crate) async fn push(&self, record: &Record<EncryptedData>) -> Result<()> {
- self.push_batch(std::iter::once(record)).await
- }
-
- /// Push a batch of records, all in one transaction
- pub(crate) async fn push_batch(
- &self,
- records: impl Iterator<Item = &Record<EncryptedData>> + Send + Sync,
- ) -> Result<()> {
- let mut tx = self.pool.begin().await?;
-
- for record in records {
- Self::save_raw(&mut tx, record).await?;
- }
-
- tx.commit().await?;
-
- Ok(())
- }
-
- pub(crate) async fn get(&self, id: RecordId) -> Result<Record<EncryptedData>> {
- let res = sqlx::query("select * from store where store.id = ?1")
- .bind(id.0.as_hyphenated().to_string())
- .map(Self::query_row)
- .fetch_one(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- pub(crate) async fn delete(&self, id: RecordId) -> Result<()> {
- sqlx::query("delete from store where id = ?1")
- .bind(id.0.as_hyphenated().to_string())
- .execute(&self.pool)
- .await?;
-
- Ok(())
- }
-
- pub(crate) async fn delete_all(&self) -> Result<()> {
- sqlx::query("delete from store").execute(&self.pool).await?;
-
- Ok(())
- }
-
- pub(crate) async fn last(
- &self,
- host: HostId,
- tag: &str,
- ) -> Result<Option<Record<EncryptedData>>> {
- let res =
- sqlx::query("select * from store where host=?1 and tag=?2 order by idx desc limit 1")
- .bind(host.0.as_hyphenated().to_string())
- .bind(tag)
- .map(Self::query_row)
- .fetch_one(&self.pool)
- .await;
-
- match res {
- Err(sqlx::Error::RowNotFound) => Ok(None),
- Err(e) => Err(eyre!("an error occurred: {}", e)),
- Ok(record) => Ok(Some(record)),
- }
- }
-
- pub(crate) async fn first(
- &self,
- host: HostId,
- tag: &str,
- ) -> Result<Option<Record<EncryptedData>>> {
- self.idx(host, tag, 0).await
- }
-
- pub(crate) async fn len_tag(&self, tag: &str) -> Result<u64> {
- let res: Result<(i64,), sqlx::Error> =
- sqlx::query_as("select count(*) from store where tag=?1")
- .bind(tag)
- .fetch_one(&self.pool)
- .await;
- match res {
- Err(e) => Err(eyre!("failed to fetch local store len: {}", e)),
- Ok(v) => Ok(v.0 as u64),
- }
- }
-
- /// Get the next `limit` records, after and including the given index
- pub(crate) async fn next(
- &self,
- host: HostId,
- tag: &str,
- idx: RecordIdx,
- limit: u64,
- ) -> Result<Vec<Record<EncryptedData>>> {
- let res = sqlx::query(
- "select * from store where idx >= ?1 and host = ?2 and tag = ?3 order by idx asc limit ?4",
- )
- .bind(idx as i64)
- .bind(host.0.as_hyphenated().to_string())
- .bind(tag)
- .bind(limit as i64)
- .map(Self::query_row)
- .fetch_all(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- /// Get the first record for a given host and tag
- pub(crate) async fn idx(
- &self,
- host: HostId,
- tag: &str,
- idx: RecordIdx,
- ) -> Result<Option<Record<EncryptedData>>> {
- let res = sqlx::query("select * from store where idx = ?1 and host = ?2 and tag = ?3")
- .bind(idx as i64)
- .bind(host.0.as_hyphenated().to_string())
- .bind(tag)
- .map(Self::query_row)
- .fetch_one(&self.pool)
- .await;
-
- match res {
- Err(sqlx::Error::RowNotFound) => Ok(None),
- Err(e) => Err(eyre!("an error occurred: {}", e)),
- Ok(v) => Ok(Some(v)),
- }
- }
-
- pub(crate) async fn status(&self) -> Result<RecordStatus> {
- let mut status = RecordStatus::new();
-
- let res: Result<Vec<(String, String, i64)>, sqlx::Error> =
- sqlx::query_as("select host, tag, max(idx) from store group by host, tag")
- .fetch_all(&self.pool)
- .await;
-
- let res = match res {
- Err(e) => return Err(eyre!("failed to fetch local store status: {}", e)),
- Ok(v) => v,
- };
-
- for i in res {
- let host = HostId(
- Uuid::from_str(i.0.as_str()).expect("failed to parse uuid for local store status"),
- );
-
- status.set_raw(host, i.1, i.2 as u64);
- }
-
- Ok(status)
- }
-
- /// Get all records for a given tag
- pub(crate) async fn all_tagged(&self, tag: &str) -> Result<Vec<Record<EncryptedData>>> {
- let res = sqlx::query("select * from store where tag = ?1 order by timestamp asc")
- .bind(tag)
- .map(Self::query_row)
- .fetch_all(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- /// Reencrypt every single item in this store with a new key
- /// Be careful - this may mess with sync.
- pub(crate) async fn re_encrypt(&self, old_key: &[u8; 32], new_key: &[u8; 32]) -> Result<()> {
- // Load all the records
- // In memory like some of the other code here
- // This will never be called in a hot loop, and only under the following circumstances
- // 1. The user has logged into a new account, with a new key. They are unlikely to have a
- // lot of data
- // 2. The user has encountered some sort of issue, and runs a maintenance command that
- // invokes this
- let all = self.load_all().await?;
-
- let re_encrypted = all
- .into_iter()
- .map(|record| record.re_encrypt::<PASETO_V4>(old_key, new_key))
- .collect::<Result<Vec<_>>>()?;
-
- // next up, we delete all the old data and reinsert the new stuff
- // do it in one transaction, so if anything fails we rollback OK
-
- let mut tx = self.pool.begin().await?;
-
- let res = sqlx::query("delete from store").execute(&mut *tx).await?;
-
- let rows = res.rows_affected();
- debug!("deleted {rows} rows");
-
- // don't call push_batch, as it will start its own transaction
- // call the underlying save_raw
-
- for record in re_encrypted {
- Self::save_raw(&mut tx, &record).await?;
- }
-
- tx.commit().await?;
-
- Ok(())
- }
-
- /// Verify that every record in this store can be decrypted with the current key
- /// Someday maybe also check each tag/record can be deserialized, but not for now.
- pub(crate) async fn verify(&self, key: &[u8; 32]) -> Result<()> {
- let all = self.load_all().await?;
-
- all.into_iter()
- .map(|record| record.decrypt::<PASETO_V4>(key))
- .collect::<Result<Vec<_>>>()?;
-
- Ok(())
- }
-
- /// Verify that every record in this store can be decrypted with the current key
- /// Someday maybe also check each tag/record can be deserialized, but not for now.
- pub(crate) async fn purge(&self, key: &[u8; 32]) -> Result<()> {
- let all = self.load_all().await?;
-
- for record in &all {
- if record.clone().decrypt::<PASETO_V4>(key).is_ok() {
- continue;
- }
-
- println!(
- "Failed to decrypt {}, deleting",
- record.id.0.as_hyphenated()
- );
-
- self.delete(record.id).await?;
- }
-
- Ok(())
- }
-}
-
-#[cfg(test)]
-mod tests {
- use crate::{
- atuin_client::{
- encryption::generate_encoded_key, record::encryption::PASETO_V4,
- settings::test_local_timeout,
- },
- atuin_common::{
- record::{DecryptedData, EncryptedData, Host, HostId, Record},
- utils::uuid_v7,
- },
- };
-
- use super::SqliteStore;
-
- fn test_record() -> Record<EncryptedData> {
- Record::builder()
- .host(Host::new(HostId(uuid_v7())))
- .version("v1".into())
- .tag(uuid_v7().simple().to_string())
- .data(EncryptedData {
- data: "1234".into(),
- content_encryption_key: "1234".into(),
- })
- .idx(0)
- .build()
- }
-
- #[tokio::test]
- async fn create_db() {
- let db = SqliteStore::new(":memory:", test_local_timeout()).await;
-
- assert!(
- db.is_ok(),
- "db could not be created, {:?}",
- db.err().unwrap()
- );
- }
-
- #[tokio::test]
- async fn push_record() {
- let db = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .unwrap();
- let record = test_record();
-
- db.push(&record).await.expect("failed to insert record");
- }
-
- #[tokio::test]
- async fn get_record() {
- let db = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .unwrap();
- let record = test_record();
- db.push(&record).await.unwrap();
-
- let new_record = db.get(record.id).await.expect("failed to fetch record");
-
- assert_eq!(record, new_record, "records are not equal");
- }
-
- #[tokio::test]
- async fn last() {
- let db = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .unwrap();
- let record = test_record();
- db.push(&record).await.unwrap();
-
- let last = db
- .last(record.host.id, record.tag.as_str())
- .await
- .expect("failed to get store len");
-
- assert_eq!(
- last.unwrap().id,
- record.id,
- "expected to get back the same record that was inserted"
- );
- }
-
- #[tokio::test]
- async fn first() {
- let db = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .unwrap();
- let record = test_record();
- db.push(&record).await.unwrap();
-
- let first = db
- .first(record.host.id, record.tag.as_str())
- .await
- .expect("failed to get store len");
-
- assert_eq!(
- first.unwrap().id,
- record.id,
- "expected to get back the same record that was inserted"
- );
- }
-
- #[tokio::test]
- async fn len() {
- let db = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .unwrap();
- let record = test_record();
- db.push(&record).await.unwrap();
-
- let len = db
- .len(record.host.id, record.tag.as_str())
- .await
- .expect("failed to get store len");
-
- assert_eq!(len, 1, "expected length of 1 after insert");
- }
-
- #[tokio::test]
- async fn len_tag() {
- let db = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .unwrap();
- let record = test_record();
- db.push(&record).await.unwrap();
-
- let len = db
- .len_tag(record.tag.as_str())
- .await
- .expect("failed to get store len");
-
- assert_eq!(len, 1, "expected length of 1 after insert");
- }
-
- #[tokio::test]
- async fn re_encrypt() {
- let store = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .unwrap();
- let (key, _) = generate_encoded_key().unwrap();
- let data = vec![0u8, 1u8, 2u8, 3u8];
- let host_id = HostId(uuid_v7());
-
- for i in 0..10 {
- let record = Record::builder()
- .host(Host::new(host_id))
- .version(String::from("test"))
- .tag(String::from("test"))
- .idx(i)
- .data(DecryptedData(data.clone()))
- .build();
-
- let record = record.encrypt::<PASETO_V4>(&key.into());
- store
- .push(&record)
- .await
- .expect("failed to push encrypted record");
- }
-
- // first, check that we can decrypt the data with the current key
- let all = store.all_tagged("test").await.unwrap();
-
- assert_eq!(all.len(), 10, "failed to fetch all records");
-
- for record in all {
- let decrypted = record.decrypt::<PASETO_V4>(&key.into()).unwrap();
- assert_eq!(decrypted.data.0, data);
- }
-
- // reencrypt the store, then check if
- // 1) it cannot be decrypted with the old key
- // 2) it can be decrypted with the new key
-
- let (new_key, _) = generate_encoded_key().unwrap();
- store
- .re_encrypt(&key.into(), &new_key.into())
- .await
- .expect("failed to re-encrypt store");
-
- let all = store.all_tagged("test").await.unwrap();
-
- for record in all.iter() {
- let decrypted = record.clone().decrypt::<PASETO_V4>(&key.into());
- assert!(
- decrypted.is_err(),
- "did not get error decrypting with old key after re-encrypt"
- )
- }
-
- for record in all {
- let decrypted = record.decrypt::<PASETO_V4>(&new_key.into()).unwrap();
- assert_eq!(decrypted.data.0, data);
- }
-
- assert_eq!(store.len(host_id, "test").await.unwrap(), 10);
- }
-}
diff --git a/crates/client/src/atuin_client/record/sync.rs b/crates/client/src/atuin_client/record/sync.rs
deleted file mode 100644
index da05533c..00000000
--- a/crates/client/src/atuin_client/record/sync.rs
+++ /dev/null
@@ -1,456 +0,0 @@
-// do a sync :O
-use std::{cmp::Ordering, fmt::Write};
-
-use eyre::{OptionExt, Result};
-use thiserror::Error;
-use tracing::error;
-
-use super::encryption::PASETO_V4;
-use crate::atuin_client::record::sqlite_store::SqliteStore;
-use crate::atuin_client::{api_client::Client, settings::Settings};
-
-use crate::atuin_common::record::{Diff, HostId, RecordId, RecordIdx, RecordStatus};
-use indicatif::{ProgressBar, ProgressState, ProgressStyle};
-
-#[derive(Error, Debug)]
-pub(crate) enum SyncError {
- #[error("an issue with the local database occurred: {msg:?}")]
- LocalStoreError { msg: String },
-
- #[error("something has gone wrong with the sync logic: {msg:?}")]
- SyncLogicError { msg: String },
-
- #[error("operational error: {msg:?}")]
- OperationalError { msg: String },
-
- #[error("a request to the sync server failed: {msg}")]
- RemoteRequestError { msg: String },
-
- #[error(
- "the encryption key on this machine does not match the data on the server. \
- this usually means a new machine was set up without copying the existing key. \
- to fix: run `atuin key` on a machine that already syncs correctly, then run \
- `atuin store rekey <key>` on this machine with the value from the other machine"
- )]
- WrongKey,
-}
-
-#[derive(Debug, Eq, PartialEq)]
-pub(crate) enum Operation {
- // Either upload or download until the states matches the below
- Upload {
- local: RecordIdx,
- remote: Option<RecordIdx>,
- host: HostId,
- tag: String,
- },
- Download {
- local: Option<RecordIdx>,
- remote: RecordIdx,
- host: HostId,
- tag: String,
- },
- Noop {
- host: HostId,
- tag: String,
- },
-}
-
-pub(crate) fn build_client(settings: &Settings) -> Result<Client<'_>, SyncError> {
- Client::new(
- &settings.sync.address,
- settings.network_connect_timeout,
- settings.network_timeout,
- settings
- .sync
- .user_id()
- .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?
- .ok_or_eyre("No sync user-id set")
- .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?,
- )
- .map_err(|e| SyncError::OperationalError { msg: e.to_string() })
-}
-
-pub(crate) async fn diff(
- client: &Client<'_>,
- store: &SqliteStore,
-) -> Result<(Vec<Diff>, RecordStatus), SyncError> {
- let local_index = store
- .status()
- .await
- .map_err(|e| SyncError::LocalStoreError { msg: e.to_string() })?;
-
- let remote_index = client
- .record_status()
- .await
- .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?;
-
- let diff = local_index.diff(&remote_index);
-
- Ok((diff, remote_index))
-}
-
-// Take a diff, along with a local store, and resolve it into a set of operations.
-// With the store as context, we can determine if a tail exists locally or not and therefore if it needs uploading or download.
-// In theory this could be done as a part of the diffing stage, but it's easier to reason
-// about and test this way
-pub(crate) fn operations(
- diffs: Vec<Diff>,
- _store: &SqliteStore,
-) -> Result<Vec<Operation>, SyncError> {
- let mut operations = Vec::with_capacity(diffs.len());
-
- for diff in diffs {
- let op = match (diff.local, diff.remote) {
- // We both have it! Could be either. Compare.
- (Some(local), Some(remote)) => match local.cmp(&remote) {
- Ordering::Equal => Operation::Noop {
- host: diff.host,
- tag: diff.tag,
- },
- Ordering::Greater => Operation::Upload {
- local,
- remote: Some(remote),
- host: diff.host,
- tag: diff.tag,
- },
- Ordering::Less => Operation::Download {
- local: Some(local),
- remote,
- host: diff.host,
- tag: diff.tag,
- },
- },
-
- // Remote has it, we don't. Gotta be download
- (None, Some(remote)) => Operation::Download {
- local: None,
- remote,
- host: diff.host,
- tag: diff.tag,
- },
-
- // We have it, remote doesn't. Gotta be upload.
- (Some(local), None) => Operation::Upload {
- local,
- remote: None,
- host: diff.host,
- tag: diff.tag,
- },
-
- // something is pretty fucked.
- (None, None) => {
- return Err(SyncError::SyncLogicError {
- msg: String::from(
- "diff has nothing for local or remote - (host, tag) does not exist",
- ),
- });
- }
- };
-
- operations.push(op);
- }
-
- // sort them - purely so we have a stable testing order, and can rely on
- // same input = same output
- // We can sort by ID so long as we continue to use UUIDv7 or something
- // with the same properties
-
- operations.sort_by_key(|op| match op {
- Operation::Noop { host, tag } => (0, *host, tag.clone()),
-
- Operation::Upload { host, tag, .. } => (1, *host, tag.clone()),
-
- Operation::Download { host, tag, .. } => (2, *host, tag.clone()),
- });
-
- Ok(operations)
-}
-
-async fn sync_upload(
- store: &SqliteStore,
- client: &Client<'_>,
- host: HostId,
- tag: String,
- local: RecordIdx,
- remote: Option<RecordIdx>,
- page_size: u64,
-) -> Result<i64, SyncError> {
- let remote = remote.unwrap_or(0);
- let expected = local - remote;
- let mut progress = 0;
-
- let pb = ProgressBar::new(expected);
- pb.set_style(ProgressStyle::with_template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {human_pos}/{human_len} ({eta})")
- .unwrap()
- .with_key("eta", |state: &ProgressState, w: &mut dyn Write| write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap())
- .progress_chars("#>-"));
-
- println!(
- "Uploading {} records to {}/{}",
- expected,
- host.0.as_simple(),
- tag
- );
-
- loop {
- let page = store
- .next(host, tag.as_str(), remote + progress, page_size)
- .await
- .map_err(|e| {
- error!("failed to read upload page: {e:?}");
-
- SyncError::LocalStoreError { msg: e.to_string() }
- })?;
-
- if page.is_empty() {
- break;
- }
-
- client.post_records(&page).await.map_err(|e| {
- error!("failed to post records: {e:?}");
-
- SyncError::RemoteRequestError { msg: e.to_string() }
- })?;
-
- progress += page.len() as u64;
- pb.set_position(progress);
-
- if progress >= expected {
- break;
- }
- }
-
- pb.finish_with_message("Uploaded records");
-
- Ok(progress as i64)
-}
-
-async fn sync_download(
- store: &SqliteStore,
- client: &Client<'_>,
- host: HostId,
- tag: String,
- local: Option<RecordIdx>,
- remote: RecordIdx,
- page_size: u64,
-) -> Result<Vec<RecordId>, SyncError> {
- let local = local.unwrap_or(0);
- let expected = remote - local;
- let mut progress = 0;
- let mut ret = Vec::new();
-
- println!(
- "Downloading {} records from {}/{}",
- expected,
- host.0.as_simple(),
- tag
- );
-
- let pb = ProgressBar::new(expected);
- pb.set_style(ProgressStyle::with_template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {human_pos}/{human_len} ({eta})")
- .unwrap()
- .with_key("eta", |state: &ProgressState, w: &mut dyn Write| write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap())
- .progress_chars("#>-"));
-
- loop {
- let page = client
- .next_records(host, tag.clone(), local + progress, page_size)
- .await
- .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?;
-
- if page.is_empty() {
- break;
- }
-
- store
- .push_batch(page.iter())
- .await
- .map_err(|e| SyncError::LocalStoreError { msg: e.to_string() })?;
-
- ret.extend(page.iter().map(|f| f.id));
-
- progress += page.len() as u64;
- pb.set_position(progress);
-
- if progress >= expected {
- break;
- }
- }
-
- pb.finish_with_message("Downloaded records");
-
- Ok(ret)
-}
-
-pub(crate) async fn sync_remote(
- client: &Client<'_>,
- operations: Vec<Operation>,
- local_store: &SqliteStore,
- page_size: u64,
-) -> Result<(i64, Vec<RecordId>), SyncError> {
- let mut uploaded = 0;
- let mut downloaded = Vec::new();
-
- // this can totally run in parallel, but lets get it working first
- for i in operations {
- match i {
- Operation::Upload {
- host,
- tag,
- local,
- remote,
- } => {
- uploaded +=
- sync_upload(local_store, client, host, tag, local, remote, page_size).await?;
- }
-
- Operation::Download {
- host,
- tag,
- local,
- remote,
- } => {
- let mut d =
- sync_download(local_store, client, host, tag, local, remote, page_size).await?;
- downloaded.append(&mut d);
- }
-
- Operation::Noop { .. } => (),
- }
- }
-
- Ok((uploaded, downloaded))
-}
-
-pub(crate) async fn check_encryption_key(
- client: &Client<'_>,
- remote_index: &RecordStatus,
- encryption_key: &[u8; 32],
-) -> Result<(), SyncError> {
- let sample = remote_index
- .hosts
- .iter()
- .flat_map(|(host, tags)| tags.keys().map(move |tag| (*host, tag.clone())))
- .next();
-
- let Some((host, tag)) = sample else {
- return Ok(());
- };
-
- let records = client
- .next_records(host, tag, 0, 1)
- .await
- .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?;
-
- let Some(record) = records.into_iter().next() else {
- return Ok(());
- };
-
- record.decrypt::<PASETO_V4>(encryption_key).map_err(|err| {
- error!("Wrong key error: {err}");
- SyncError::WrongKey
- })?;
-
- Ok(())
-}
-
-pub(crate) async fn sync(
- settings: &Settings,
- store: &SqliteStore,
- encryption_key: &[u8; 32],
-) -> Result<(i64, Vec<RecordId>), SyncError> {
- let client = build_client(settings)?;
- let (diff, remote_index) = diff(&client, store).await?;
-
- // Bail before mutating either side if the local key can't read the remote.
- check_encryption_key(&client, &remote_index, encryption_key).await?;
-
- let operations = operations(diff, store)?;
- let (uploaded, downloaded) = sync_remote(&client, operations, store, 100).await?;
-
- Ok((uploaded, downloaded))
-}
-
-#[cfg(test)]
-mod tests {
- use crate::atuin_client::record::sync::Operation;
- use crate::atuin_common::record::{Diff, EncryptedData, HostId, Record};
-
- use crate::atuin_client::{
- record::{
- sqlite_store::SqliteStore,
- sync::{self},
- },
- settings::test_local_timeout,
- };
-
- fn test_record() -> Record<EncryptedData> {
- Record::builder()
- .host(crate::atuin_common::record::Host::new(HostId(
- crate::atuin_common::utils::uuid_v7(),
- )))
- .version("v1".into())
- .tag(crate::atuin_common::utils::uuid_v7().simple().to_string())
- .data(EncryptedData {
- data: String::new(),
- content_encryption_key: String::new(),
- })
- .idx(0)
- .build()
- }
-
- // Take a list of local records, and a list of remote records.
- // Return the local database, and a diff of local/remote, ready to build
- // ops
- async fn build_test_diff(
- local_records: Vec<Record<EncryptedData>>,
- remote_records: Vec<Record<EncryptedData>>,
- ) -> (SqliteStore, Vec<Diff>) {
- let local_store = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .expect("failed to open in memory sqlite");
- let remote_store = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .expect("failed to open in memory sqlite"); // "remote"
-
- for i in local_records {
- local_store.push(&i).await.unwrap();
- }
-
- for i in remote_records {
- remote_store.push(&i).await.unwrap();
- }
-
- let local_index = local_store.status().await.unwrap();
- let remote_index = remote_store.status().await.unwrap();
-
- let diff = local_index.diff(&remote_index);
-
- (local_store, diff)
- }
-
- #[tokio::test]
- async fn test_basic_diff() {
- // a diff where local is ahead of remote. nothing else.
-
- let record = test_record();
- let (store, diff) = build_test_diff(vec![record.clone()], vec![]).await;
-
- assert_eq!(diff.len(), 1);
-
- let operations = sync::operations(diff, &store).unwrap();
-
- assert_eq!(operations.len(), 1);
-
- assert_eq!(
- operations[0],
- Operation::Upload {
- host: record.host.id,
- tag: record.tag,
- local: record.idx,
- remote: None,
- }
- );
- }
-}
diff --git a/crates/client/src/atuin_client/secrets.rs b/crates/client/src/atuin_client/secrets.rs
deleted file mode 100644
index 74d47ea6..00000000
--- a/crates/client/src/atuin_client/secrets.rs
+++ /dev/null
@@ -1,223 +0,0 @@
-// 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::atuin_client::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/client/src/atuin_client/settings/meta.rs b/crates/client/src/atuin_client/settings/meta.rs
index cc5afcf7..7993ef6d 100644
--- a/crates/client/src/atuin_client/settings/meta.rs
+++ b/crates/client/src/atuin_client/settings/meta.rs
@@ -7,7 +7,7 @@ pub(crate) struct Settings {
impl Default for Settings {
fn default() -> Self {
- let dir = crate::atuin_common::utils::data_dir();
+ let dir = turtle_common::utils::data_dir();
let path = dir.join("meta.db");
Self {
diff --git a/crates/client/src/atuin_client/settings.rs b/crates/client/src/atuin_client/settings/mod.rs
index 9e14c4c8..0bddc09c 100644
--- a/crates/client/src/atuin_client/settings.rs
+++ b/crates/client/src/atuin_client/settings/mod.rs
@@ -6,8 +6,7 @@ use tokio::sync::OnceCell;
use tracing::info;
use uuid::Uuid;
-use crate::atuin_common::utils;
-use crate::{atuin_client::encryption::decode_key, atuin_common::record::HostId};
+use crate::aclient::encryption::decode_key;
use clap::ValueEnum;
use config::{
Config, ConfigBuilder, Environment, File as ConfigFile, FileFormat, builder::DefaultState,
@@ -18,10 +17,12 @@ use regex::RegexSet;
use serde::{Deserialize, Serialize};
use serde_with::DeserializeFromStr;
use time::{OffsetDateTime, UtcOffset, format_description::FormatItem, macros::format_description};
+use turtle_common::record::HostId;
+use turtle_common::utils;
static DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
static META_CONFIG: OnceLock<(String, f64)> = OnceLock::new();
-static META_STORE: OnceCell<crate::atuin_client::meta::MetaStore> = OnceCell::const_new();
+static META_STORE: OnceCell<crate::aclient::meta::MetaStore> = OnceCell::const_new();
pub(crate) mod meta;
pub(crate) mod watcher;
@@ -852,7 +853,7 @@ impl Sync {
#[derive(Clone, Debug, Deserialize, Serialize)]
#[expect(clippy::struct_excessive_bools)]
-pub(crate) struct Settings {
+pub struct Settings {
pub(crate) data_dir: Option<String>,
pub(crate) dialect: Dialect,
pub(crate) timezone: Timezone,
@@ -938,13 +939,13 @@ pub(crate) struct Settings {
impl Settings {
// -- Meta store: lazily initialized on first access --
- pub(crate) async fn meta_store() -> Result<&'static crate::atuin_client::meta::MetaStore> {
+ pub(crate) async fn meta_store() -> Result<&'static crate::aclient::meta::MetaStore> {
META_STORE
.get_or_try_init(|| async {
let (db_path, timeout) = META_CONFIG.get().ok_or_else(|| {
eyre!("meta store config not set — Settings::new() has not been called")
})?;
- crate::atuin_client::meta::MetaStore::new(db_path, *timeout).await
+ crate::aclient::meta::MetaStore::new(db_path, *timeout).await
})
.await
}
@@ -1464,7 +1465,7 @@ mod tests {
assert_eq!(meta_db_path, custom_dir.join("meta.db").to_str().unwrap());
assert_eq!(
daemon_socket_path,
- crate::atuin_common::utils::runtime_dir()
+ turtle_common::utils::runtime_dir()
.join("atuin.sock")
.to_str()
.unwrap()
diff --git a/crates/client/src/atuin_client/settings/watcher.rs b/crates/client/src/atuin_client/settings/watcher.rs
index 5eec3692..01d20855 100644
--- a/crates/client/src/atuin_client/settings/watcher.rs
+++ b/crates/client/src/atuin_client/settings/watcher.rs
@@ -6,7 +6,7 @@
//! # Example
//!
//! ```no_run
-//! use crate::atuin_client::settings::watcher::global_settings_watcher;
+//! use crate::aclient::settings::watcher::global_settings_watcher;
//!
//! async fn example() -> eyre::Result<()> {
//! let watcher = global_settings_watcher()?;
@@ -96,7 +96,7 @@ impl SettingsWatcher {
/// Get the config file path.
fn config_path() -> PathBuf {
let config_dir = std::env::var("ATUIN_CONFIG_DIR")
- .map_or_else(|_| crate::atuin_common::utils::config_dir(), PathBuf::from);
+ .map_or_else(|_| turtle_common::utils::config_dir(), PathBuf::from);
config_dir.join("config.toml")
}
diff --git a/crates/client/src/atuin_client/utils.rs b/crates/client/src/atuin_client/utils.rs
deleted file mode 100644
index 989f9fc1..00000000
--- a/crates/client/src/atuin_client/utils.rs
+++ /dev/null
@@ -1,92 +0,0 @@
-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.
-macro_rules! setup_db {
- (
- $db_path:expr,
- $a_timeout:expr,
- $opts:expr,
- $m_name:literal $(,)?
- ) => {{
- async fn migrate(pool: &SqlitePool) -> sqlx::Result<()> {
- { sqlx::sqlx_macros::migrate!($m_name) }.run(pool).await?;
- Ok(())
- }
-
- crate::atuin_client::utils::setup_db_inner($db_path, $a_timeout, $opts, migrate)
- }};
-}
-pub(crate) use setup_db;
-
-use std::{os::fd::AsRawFd, path::Path, time::Duration};
-
-use fs_err::OpenOptions;
-use sqlx::{
- SqlitePool,
- sqlite::{SqliteConnectOptions, SqlitePoolOptions},
-};
-use tracing::debug;
-
-/// Helper for `setup_db!`
-pub(crate) async fn setup_db_inner(
- db_path: &Path,
- acquire_timeout: f64,
- mk_opts: fn(&str) -> sqlx::Result<SqliteConnectOptions>,
- migrate: impl AsyncFn(&SqlitePool) -> sqlx::Result<()>,
-) -> sqlx::Result<SqlitePool> {
- async fn open_db(timeout: f64, opts: SqliteConnectOptions) -> sqlx::Result<SqlitePool> {
- let pool = SqlitePoolOptions::new()
- .acquire_timeout(Duration::from_secs_f64(timeout))
- .connect_with(opts)
- .await?;
-
- Ok(pool)
- }
-
- {
- let file = OpenOptions::new()
- .read(true)
- .write(true)
- .create(true)
- .open(db_path)?;
-
- // Lock the db file while we are running the migrations.
- // Why? Because there is a small chance that we start running migrations (e.g. as the daemon)
- // and then another process is started, which will also try to run migrations.
- // Essentially, one of the processes will receive with a SQLite UNIQUE constraint failure.
- // So let's avoid that possibility from the start.
- file.lock()?;
-
- let pool = open_db(
- acquire_timeout,
- mk_opts(format!("/proc/self/fd/{}", file.as_raw_fd()).as_str())?,
- )
- .await?;
-
- debug!("running sqlite database setup");
-
- migrate(&pool).await?;
-
- file.unlock()?;
- }
-
- let real_opts = mk_opts(db_path.to_str().expect("Should be utf-8"))?;
- let pool = open_db(acquire_timeout, real_opts).await?;
-
- Ok(pool)
-}