From 92a8fd40209c5595475fb0288741d0347fe244c4 Mon Sep 17 00:00:00 2001 From: Benedikt Peetz Date: Thu, 9 Jul 2026 22:05:46 +0200 Subject: chore: Move more stuff out of atuin-client --- crates/client/src/atuin_common/api.rs | 22 --- crates/client/src/atuin_common/mod.rs | 58 ------- crates/client/src/atuin_common/record.rs | 284 ------------------------------- crates/client/src/atuin_common/shell.rs | 52 ------ crates/client/src/atuin_common/tls.rs | 15 -- crates/client/src/atuin_common/utils.rs | 257 ---------------------------- 6 files changed, 688 deletions(-) delete mode 100644 crates/client/src/atuin_common/api.rs delete mode 100644 crates/client/src/atuin_common/mod.rs delete mode 100644 crates/client/src/atuin_common/record.rs delete mode 100644 crates/client/src/atuin_common/shell.rs delete mode 100644 crates/client/src/atuin_common/tls.rs delete mode 100644 crates/client/src/atuin_common/utils.rs (limited to 'crates/client/src/atuin_common') diff --git a/crates/client/src/atuin_common/api.rs b/crates/client/src/atuin_common/api.rs deleted file mode 100644 index 0868943d..00000000 --- a/crates/client/src/atuin_common/api.rs +++ /dev/null @@ -1,22 +0,0 @@ -use semver::Version; -use serde::{Deserialize, Serialize}; -use std::borrow::Cow; -use std::sync::LazyLock; - -// the usage of X- has been deprecated for quite along time, it turns out -pub(crate) static ATUIN_HEADER_VERSION: &str = "Atuin-Version"; -pub(crate) static ATUIN_CARGO_VERSION: &str = env!("CARGO_PKG_VERSION"); - -pub(crate) static ATUIN_VERSION: LazyLock = - LazyLock::new(|| Version::parse(ATUIN_CARGO_VERSION).expect("failed to parse self semver")); - -#[derive(Debug, Serialize, Deserialize)] -pub(crate) struct ErrorResponse<'a> { - pub(crate) reason: Cow<'a, str>, -} - -#[derive(Debug, Serialize, Deserialize)] -pub(crate) struct IndexResponse { - pub(crate) homage: String, - pub(crate) version: String, -} diff --git a/crates/client/src/atuin_common/mod.rs b/crates/client/src/atuin_common/mod.rs deleted file mode 100644 index 635c9fc3..00000000 --- a/crates/client/src/atuin_common/mod.rs +++ /dev/null @@ -1,58 +0,0 @@ -/// Defines a new UUID type wrapper -macro_rules! new_uuid { - ($name:ident) => { - #[derive( - Debug, - Copy, - Clone, - PartialEq, - Eq, - Hash, - PartialOrd, - Ord, - serde::Serialize, - serde::Deserialize, - )] - #[serde(transparent)] - pub(crate) struct $name(pub(crate) Uuid); - - impl sqlx::Type for $name - where - Uuid: sqlx::Type, - { - fn type_info() -> ::TypeInfo { - Uuid::type_info() - } - } - - impl<'r, DB: sqlx::Database> sqlx::Decode<'r, DB> for $name - where - Uuid: sqlx::Decode<'r, DB>, - { - fn decode( - value: DB::ValueRef<'r>, - ) -> std::result::Result { - Uuid::decode(value).map(Self) - } - } - - impl<'q, DB: sqlx::Database> sqlx::Encode<'q, DB> for $name - where - Uuid: sqlx::Encode<'q, DB>, - { - fn encode_by_ref( - &self, - buf: &mut DB::ArgumentBuffer<'q>, - ) -> Result> - { - self.0.encode_by_ref(buf) - } - } - }; -} - -pub(crate) mod api; -pub(crate) mod record; -pub(crate) mod shell; -pub(crate) mod tls; -pub(crate) mod utils; diff --git a/crates/client/src/atuin_common/record.rs b/crates/client/src/atuin_common/record.rs deleted file mode 100644 index f8f9f8a7..00000000 --- a/crates/client/src/atuin_common/record.rs +++ /dev/null @@ -1,284 +0,0 @@ -use std::collections::HashMap; - -use eyre::Result; -use serde::{Deserialize, Serialize}; -use typed_builder::TypedBuilder; -use uuid::Uuid; - -#[derive(Clone, Debug, PartialEq)] -pub(crate) struct DecryptedData(pub(crate) Vec); - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct EncryptedData { - pub(crate) data: String, - pub(crate) content_encryption_key: String, -} - -#[derive(Debug, PartialEq, PartialOrd, Ord, Eq)] -pub(crate) struct Diff { - pub(crate) host: HostId, - pub(crate) tag: String, - pub(crate) local: Option, - pub(crate) remote: Option, -} - -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] -pub(crate) struct Host { - pub(crate) id: HostId, - pub(crate) name: String, -} - -impl Host { - pub(crate) fn new(id: HostId) -> Self { - Self { - id, - name: String::new(), - } - } -} - -new_uuid!(RecordId); -new_uuid!(HostId); - -pub(crate) type RecordIdx = u64; - -/// A single record stored inside of our local database -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TypedBuilder)] -pub(crate) struct Record { - /// a unique ID - #[builder(default = RecordId(crate::atuin_common::utils::uuid_v7()))] - pub(crate) id: RecordId, - - /// The integer record ID. This is only unique per (host, tag). - pub(crate) idx: RecordIdx, - - /// The unique ID of the host. - // TODO(ellie): Optimize the storage here. We use a bunch of IDs, and currently store - // as strings. I would rather avoid normalization, so store as UUID binary instead of - // encoding to a string and wasting much more storage. - pub(crate) host: Host, - - /// The creation time in nanoseconds since unix epoch - #[builder(default = time::OffsetDateTime::now_utc().unix_timestamp_nanos() as u64)] - pub(crate) timestamp: u64, - - /// The version the data in the entry conforms to - // However we want to track versions for this tag, eg v2 - pub(crate) version: String, - - /// The type of data we are storing here. Eg, "history" - pub(crate) tag: String, - - /// Some data. This can be anything you wish to store. Use the tag field to know how to handle it. - pub(crate) data: Data, -} - -/// Extra data from the record that should be encoded in the data -#[derive(Debug, Copy, Clone)] -pub(crate) struct AdditionalData<'a> { - pub(crate) id: &'a RecordId, - pub(crate) idx: &'a u64, - pub(crate) version: &'a str, - pub(crate) tag: &'a str, - pub(crate) host: &'a HostId, -} - -/// An index representing the current state of the record stores -/// This can be both remote, or local, and compared in either direction -#[derive(Debug, Serialize, Deserialize)] -pub(crate) struct RecordStatus { - // A map of host -> tag -> max(idx) - pub(crate) hosts: HashMap>, -} - -impl Default for RecordStatus { - fn default() -> Self { - Self::new() - } -} - -impl Extend<(HostId, String, RecordIdx)> for RecordStatus { - fn extend>(&mut self, iter: T) { - for (host, tag, tail_idx) in iter { - self.set_raw(host, tag, tail_idx); - } - } -} - -impl RecordStatus { - pub(crate) fn new() -> Self { - Self { - hosts: HashMap::new(), - } - } - - /// Insert a new tail record into the store - pub(crate) fn set_raw(&mut self, host: HostId, tag: String, tail_id: RecordIdx) { - self.hosts.entry(host).or_default().insert(tag, tail_id); - } - - pub(crate) fn get(&self, host: HostId, tag: &str) -> Option { - self.hosts.get(&host).and_then(|v| v.get(tag)).copied() - } - - /// Diff this index with another, likely remote index. - /// The two diffs can then be reconciled, and the optimal change set calculated - /// Returns a tuple, with (host, tag, Option(OTHER)) - /// OTHER is set to the value of the idx on the other machine. If it is greater than our index, - /// then we need to do some downloading. If it is smaller, then we need to do some uploading - /// Note that we cannot upload if we are not the owner of the record store - hosts can only - /// write to their own store. - pub(crate) fn diff(&self, other: &Self) -> Vec { - let mut ret = Vec::new(); - - // First, we check if other has everything that self has - for (host, tag_map) in &self.hosts { - for (tag, idx) in tag_map { - match other.get(*host, tag) { - // The other store is all up to date! No diff. - Some(t) if t.eq(idx) => (), - - // The other store does exist, and it is either ahead or behind us. A diff regardless - Some(t) => ret.push(Diff { - host: *host, - tag: tag.clone(), - local: Some(*idx), - remote: Some(t), - }), - - // The other store does not exist :O - None => ret.push(Diff { - host: *host, - tag: tag.clone(), - local: Some(*idx), - remote: None, - }), - } - } - } - - // At this point, there is a single case we have not yet considered. - // If the other store knows of a tag that we are not yet aware of, then the diff will be missed - - // account for that! - for (host, tag_map) in &other.hosts { - for (tag, idx) in tag_map { - match self.get(*host, tag) { - // If we have this host/tag combo, the comparison and diff will have already happened above - Some(_) => (), - - None => ret.push(Diff { - host: *host, - tag: tag.clone(), - remote: Some(*idx), - local: None, - }), - } - } - } - - // Stability is a nice property to have - ret.sort(); - ret - } -} - -pub(crate) trait Encryption { - fn re_encrypt( - data: EncryptedData, - ad: AdditionalData<'_>, - old_key: &[u8; 32], - new_key: &[u8; 32], - ) -> Result { - let data = Self::decrypt(data, ad, old_key)?; - Ok(Self::encrypt(data, ad, new_key)) - } - fn encrypt(data: DecryptedData, ad: AdditionalData<'_>, key: &[u8; 32]) -> EncryptedData; - fn decrypt( - data: EncryptedData, - ad: AdditionalData<'_>, - key: &[u8; 32], - ) -> Result; -} - -impl Record { - pub(crate) fn encrypt(self, key: &[u8; 32]) -> Record { - let ad = AdditionalData { - id: &self.id, - version: &self.version, - tag: &self.tag, - host: &self.host.id, - idx: &self.idx, - }; - Record { - data: E::encrypt(self.data, ad, key), - id: self.id, - host: self.host, - idx: self.idx, - timestamp: self.timestamp, - version: self.version, - tag: self.tag, - } - } -} - -impl Record { - pub(crate) fn decrypt(self, key: &[u8; 32]) -> Result> { - let ad = AdditionalData { - id: &self.id, - version: &self.version, - tag: &self.tag, - host: &self.host.id, - idx: &self.idx, - }; - Ok(Record { - data: E::decrypt(self.data, ad, key)?, - id: self.id, - host: self.host, - idx: self.idx, - timestamp: self.timestamp, - version: self.version, - tag: self.tag, - }) - } - - pub(crate) fn re_encrypt( - self, - old_key: &[u8; 32], - new_key: &[u8; 32], - ) -> Result { - let ad = AdditionalData { - id: &self.id, - version: &self.version, - tag: &self.tag, - host: &self.host.id, - idx: &self.idx, - }; - Ok(Self { - data: E::re_encrypt(self.data, ad, old_key, new_key)?, - id: self.id, - host: self.host, - idx: self.idx, - timestamp: self.timestamp, - version: self.version, - tag: self.tag, - }) - } -} - -#[cfg(test)] -mod tests { - use crate::atuin_common::record::{Host, HostId}; - - use super::{DecryptedData, Record}; - - fn test_record() -> Record { - Record::builder() - .host(Host::new(HostId(crate::atuin_common::utils::uuid_v7()))) - .version("v1".into()) - .tag(crate::atuin_common::utils::uuid_v7().simple().to_string()) - .data(DecryptedData(vec![0, 1, 2, 3])) - .idx(0) - .build() - } -} diff --git a/crates/client/src/atuin_common/shell.rs b/crates/client/src/atuin_common/shell.rs deleted file mode 100644 index 880ff00f..00000000 --- a/crates/client/src/atuin_common/shell.rs +++ /dev/null @@ -1,52 +0,0 @@ -#[derive(PartialEq)] -pub(crate) enum Shell { - Sh, - Bash, - Fish, - Zsh, - Xonsh, - Nu, - Powershell, - - Unknown, -} - -impl std::fmt::Display for Shell { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let shell = match self { - Self::Bash => "bash", - Self::Fish => "fish", - Self::Zsh => "zsh", - Self::Nu => "nu", - Self::Xonsh => "xonsh", - Self::Sh => "sh", - Self::Powershell => "powershell", - - Self::Unknown => "unknown", - }; - - write!(f, "{shell}") - } -} - -impl Shell { - pub(crate) fn from_env() -> Self { - std::env::var("ATUIN_SHELL").map_or(Self::Unknown, |shell| { - Self::from_string(shell.trim().to_lowercase().as_str()) - }) - } - - pub(crate) fn from_string(name: &str) -> Self { - match name { - "bash" => Self::Bash, - "fish" => Self::Fish, - "zsh" => Self::Zsh, - "xonsh" => Self::Xonsh, - "nu" => Self::Nu, - "sh" => Self::Sh, - "powershell" => Self::Powershell, - - _ => Self::Unknown, - } - } -} diff --git a/crates/client/src/atuin_common/tls.rs b/crates/client/src/atuin_common/tls.rs deleted file mode 100644 index 3736f878..00000000 --- a/crates/client/src/atuin_common/tls.rs +++ /dev/null @@ -1,15 +0,0 @@ -use std::sync::Once; - -static INIT: Once = Once::new(); - -/// Ensure the rustls crypto provider (ring) is installed. -/// -/// Must be called before creating any reqwest clients. Safe to call -/// multiple times — only the first call installs the provider. -pub(crate) fn ensure_crypto_provider() { - INIT.call_once(|| { - rustls::crypto::ring::default_provider() - .install_default() - .expect("Failed to install rustls crypto provider"); - }); -} diff --git a/crates/client/src/atuin_common/utils.rs b/crates/client/src/atuin_common/utils.rs deleted file mode 100644 index dbe9dfbc..00000000 --- a/crates/client/src/atuin_common/utils.rs +++ /dev/null @@ -1,257 +0,0 @@ -use std::borrow::Cow; -use std::env; -use std::path::{Path, PathBuf}; - -use uuid::Uuid; - -pub(crate) fn uuid_v7() -> Uuid { - Uuid::now_v7() -} - -pub(crate) fn has_git_dir(path: &str) -> bool { - let mut gitdir = PathBuf::from(path); - gitdir.push(".git"); - - gitdir.exists() -} - -// in a git worktree, .git is a file containing "gitdir: " pointing -// to the main repo's .git/worktrees/ directory. follow the pointer -// back to the main repo root so all worktrees share a workspace. -fn resolve_git_worktree(path: &Path) -> Option { - let git_path = path.join(".git"); - - if !git_path.is_file() { - return None; - } - - let contents = std::fs::read_to_string(&git_path).ok()?; - let gitdir_str = contents.strip_prefix("gitdir: ")?.trim(); - - let gitdir = PathBuf::from(gitdir_str); - let gitdir = if gitdir.is_absolute() { - gitdir - } else { - path.join(gitdir_str) - }; - - // walk up from e.g. /repo/.git/worktrees/feature to find /repo - let mut candidate = gitdir.as_path(); - while let Some(parent) = candidate.parent() { - if parent.join(".git").is_dir() { - return Some(parent.to_path_buf()); - } - candidate = parent; - } - - None -} - -// detect if any parent dir has a git repo in it -// I really don't want to bring in libgit for something simple like this -// If we start to do anything more advanced, then perhaps -pub(crate) fn in_git_repo(path: &str) -> Option { - let mut gitdir = PathBuf::from(path); - - while gitdir.parent().is_some() && !has_git_dir(gitdir.to_str().unwrap()) { - gitdir.pop(); - } - - // No parent? then we hit root, finding no git - if gitdir.parent().is_some() { - // if .git is a file (worktree), resolve to the main repo root - if let Some(main_repo) = resolve_git_worktree(&gitdir) { - return Some(main_repo); - } - return Some(gitdir); - } - - None -} - -// TODO: more reliable, more tested -// I don't want to use ProjectDirs, it puts config in awkward places on -// mac. Data too. Seems to be more intended for GUI apps. - -pub(crate) fn home_dir() -> PathBuf { - directories::BaseDirs::new() - .map(|d| d.home_dir().to_path_buf()) - .expect("could not determine home directory") -} - -pub(crate) fn config_dir() -> PathBuf { - let config_dir = - env::var("XDG_CONFIG_HOME").map_or_else(|_| home_dir().join(".config"), PathBuf::from); - config_dir.join("atuin") -} - -pub(crate) fn data_dir() -> PathBuf { - let data_dir = env::var("XDG_DATA_HOME") - .map_or_else(|_| home_dir().join(".local").join("share"), PathBuf::from); - - data_dir.join("atuin") -} - -pub(crate) fn runtime_dir() -> PathBuf { - env::var("XDG_RUNTIME_DIR").map_or_else(|_| data_dir(), PathBuf::from) -} - -pub(crate) fn logs_dir() -> PathBuf { - home_dir().join(".atuin").join("logs") -} - -pub(crate) fn get_current_dir() -> String { - // Prefer PWD environment variable over cwd if available to better support symbolic links - env::var("PWD").unwrap_or_else(|_| { - env::current_dir().map_or_else(|_| String::new(), |dir| dir.display().to_string()) - }) -} - -pub(crate) fn broken_symlink>(path: P) -> bool { - let path = path.into(); - path.is_symlink() && !path.exists() -} - -/// Extension trait for anything that can behave like a string to make it easy to escape control -/// characters. -/// -/// Intended to help prevent control characters being printed and interpreted by the terminal when -/// printing history as well as to ensure the commands that appear in the interactive search -/// reflect the actual command run rather than just the printable characters. -pub(crate) trait Escapable: AsRef { - fn escape_control(&self) -> Cow<'_, str> { - if self.as_ref().contains(|c: char| c.is_ascii_control()) { - let mut remaining = self.as_ref(); - // Not a perfect way to reserve space but should reduce the allocations - let mut buf = String::with_capacity(remaining.len()); - while let Some(i) = remaining.find(|c: char| c.is_ascii_control()) { - // safe to index with `..i`, `i` and `i+1..` as part[i] is a single byte ascii char - buf.push_str(&remaining[..i]); - buf.push('^'); - buf.push(match remaining.as_bytes()[i] { - 0x7F => '?', - code => char::from_u32(u32::from(code) + 64).unwrap(), - }); - remaining = &remaining[i + 1..]; - } - buf.push_str(remaining); - buf.into() - } else { - self.as_ref().into() - } - } -} - -impl> Escapable for T {} - -#[cfg(test)] -mod tests { - use super::{Cow, Escapable, Uuid, env, in_git_repo, uuid_v7}; - - use std::collections::HashSet; - - #[test] - fn uuid_is_unique() { - let how_many: usize = 1_000_000; - - // for peace of mind - let mut uuids: HashSet = HashSet::with_capacity(how_many); - - // there will be many in the same millisecond - for _ in 0..how_many { - let uuid = uuid_v7(); - uuids.insert(uuid); - } - - assert_eq!(uuids.len(), how_many); - } - - #[test] - fn escape_control_characters() { - use super::Escapable; - // CSI colour sequence - assert_eq!("\x1b[31mfoo".escape_control(), "^[[31mfoo"); - - // Tabs count as control chars - assert_eq!("foo\tbar".escape_control(), "foo^Ibar"); - - // space is in control char range but should be excluded - assert_eq!("two words".escape_control(), "two words"); - - // unicode multi-byte characters - let s = "🐢\x1b[32m🦀"; - assert_eq!(s.escape_control(), s.replace("\x1b", "^[")); - } - - #[test] - fn escape_no_control_characters() { - use super::Escapable as _; - assert!(matches!( - "no control characters".escape_control(), - Cow::Borrowed(_) - )); - assert!(matches!( - "with \x1b[31mcontrol\x1b[0m characters".escape_control(), - Cow::Owned(_) - )); - } - - #[cfg(not(windows))] - #[test] - fn in_git_repo_regular() { - // regular git repo should resolve to the directory containing .git - let tmp = env::temp_dir().join("atuin-test-regular-git"); - drop(std::fs::remove_dir_all(&tmp)); - let subdir = tmp.join("src").join("deep"); - std::fs::create_dir_all(&subdir).unwrap(); - std::fs::create_dir_all(tmp.join(".git")).unwrap(); - - let result = in_git_repo(subdir.to_str().unwrap()); - assert_eq!(result, Some(tmp.clone())); - - std::fs::remove_dir_all(&tmp).unwrap(); - } - - #[cfg(not(windows))] - #[test] - fn in_git_repo_worktree_resolves_to_main_repo() { - // worktree .git is a file pointing back to the main repo — - // in_git_repo should follow it so all worktrees share a workspace - let tmp = env::temp_dir().join("atuin-test-worktree-git"); - drop(std::fs::remove_dir_all(&tmp)); - - // main repo at tmp/main with a real .git directory - let main_repo = tmp.join("main"); - let worktree_git_dir = main_repo.join(".git").join("worktrees").join("feature"); - std::fs::create_dir_all(&worktree_git_dir).unwrap(); - - // worktree at tmp/worktree with a .git file - let worktree = tmp.join("worktree"); - let worktree_subdir = worktree.join("src"); - std::fs::create_dir_all(&worktree_subdir).unwrap(); - std::fs::write( - worktree.join(".git"), - format!("gitdir: {}", worktree_git_dir.to_str().unwrap()), - ) - .unwrap(); - - // should resolve to the main repo root, not the worktree root - let result = in_git_repo(worktree_subdir.to_str().unwrap()); - assert_eq!(result, Some(main_repo.clone())); - - std::fs::remove_dir_all(&tmp).unwrap(); - } - - #[test] - fn dumb_random_test() { - // Obviously not a test of randomness, but make sure we haven't made some - // catastrophic error - - assert_ne!(crypto_random_string::<1>(), crypto_random_string::<1>()); - assert_ne!(crypto_random_string::<2>(), crypto_random_string::<2>()); - assert_ne!(crypto_random_string::<4>(), crypto_random_string::<4>()); - assert_ne!(crypto_random_string::<8>(), crypto_random_string::<8>()); - assert_ne!(crypto_random_string::<16>(), crypto_random_string::<16>()); - assert_ne!(crypto_random_string::<32>(), crypto_random_string::<32>()); - } -} -- cgit v1.3.1