From 95cc472037fcb3207b510e67f1a44af4e2a2cae9 Mon Sep 17 00:00:00 2001 From: Ellie Huxtable Date: Thu, 18 Apr 2024 16:41:28 +0100 Subject: chore: move crates into crates/ dir (#1958) I'd like to tidy up the root a little, and it's nice to have all the rust crates in one place --- atuin-client/Cargo.toml | 73 -- atuin-client/config.toml | 210 ---- .../migrations/20210422143411_create_history.sql | 16 - .../migrations/20220505083406_create-events.sql | 11 - .../20220806155627_interactive_search_index.sql | 6 - .../migrations/20230315220114_drop-events.sql | 2 - .../migrations/20230319185725_deleted_at.sql | 2 - .../20230531212437_create-records.sql | 16 - .../20231127090831_create-store.sql | 15 - atuin-client/src/api_client.rs | 415 ------- atuin-client/src/database.rs | 1128 -------------------- atuin-client/src/encryption.rs | 430 -------- atuin-client/src/history.rs | 517 --------- atuin-client/src/history/builder.rs | 99 -- atuin-client/src/history/store.rs | 410 ------- atuin-client/src/import/bash.rs | 218 ---- atuin-client/src/import/fish.rs | 179 ---- atuin-client/src/import/mod.rs | 111 -- atuin-client/src/import/nu.rs | 67 -- atuin-client/src/import/nu_histdb.rs | 113 -- atuin-client/src/import/resh.rs | 140 --- atuin-client/src/import/xonsh.rs | 233 ---- atuin-client/src/import/xonsh_sqlite.rs | 217 ---- atuin-client/src/import/zsh.rs | 229 ---- atuin-client/src/import/zsh_histdb.rs | 247 ----- atuin-client/src/kv.rs | 265 ----- atuin-client/src/lib.rs | 21 - atuin-client/src/ordering.rs | 32 - atuin-client/src/record/encryption.rs | 373 ------- atuin-client/src/record/mod.rs | 6 - atuin-client/src/record/sqlite_store.rs | 641 ----------- atuin-client/src/record/store.rs | 60 -- atuin-client/src/record/sync.rs | 607 ----------- atuin-client/src/secrets.rs | 59 - atuin-client/src/settings.rs | 784 -------------- atuin-client/src/settings/dotfiles.rs | 6 - atuin-client/src/sync.rs | 210 ---- atuin-client/src/utils.rs | 14 - atuin-client/tests/data/xonsh-history.sqlite | Bin 12288 -> 0 bytes ...xonsh-82eafbf5-9f43-489a-80d2-61c7dc6ef542.json | 12 - ...xonsh-de16af90-9148-4461-8df3-5b5659c6420d.json | 12 - 41 files changed, 8206 deletions(-) delete mode 100644 atuin-client/Cargo.toml delete mode 100644 atuin-client/config.toml delete mode 100644 atuin-client/migrations/20210422143411_create_history.sql delete mode 100644 atuin-client/migrations/20220505083406_create-events.sql delete mode 100644 atuin-client/migrations/20220806155627_interactive_search_index.sql delete mode 100644 atuin-client/migrations/20230315220114_drop-events.sql delete mode 100644 atuin-client/migrations/20230319185725_deleted_at.sql delete mode 100644 atuin-client/record-migrations/20230531212437_create-records.sql delete mode 100644 atuin-client/record-migrations/20231127090831_create-store.sql delete mode 100644 atuin-client/src/api_client.rs delete mode 100644 atuin-client/src/database.rs delete mode 100644 atuin-client/src/encryption.rs delete mode 100644 atuin-client/src/history.rs delete mode 100644 atuin-client/src/history/builder.rs delete mode 100644 atuin-client/src/history/store.rs delete mode 100644 atuin-client/src/import/bash.rs delete mode 100644 atuin-client/src/import/fish.rs delete mode 100644 atuin-client/src/import/mod.rs delete mode 100644 atuin-client/src/import/nu.rs delete mode 100644 atuin-client/src/import/nu_histdb.rs delete mode 100644 atuin-client/src/import/resh.rs delete mode 100644 atuin-client/src/import/xonsh.rs delete mode 100644 atuin-client/src/import/xonsh_sqlite.rs delete mode 100644 atuin-client/src/import/zsh.rs delete mode 100644 atuin-client/src/import/zsh_histdb.rs delete mode 100644 atuin-client/src/kv.rs delete mode 100644 atuin-client/src/lib.rs delete mode 100644 atuin-client/src/ordering.rs delete mode 100644 atuin-client/src/record/encryption.rs delete mode 100644 atuin-client/src/record/mod.rs delete mode 100644 atuin-client/src/record/sqlite_store.rs delete mode 100644 atuin-client/src/record/store.rs delete mode 100644 atuin-client/src/record/sync.rs delete mode 100644 atuin-client/src/secrets.rs delete mode 100644 atuin-client/src/settings.rs delete mode 100644 atuin-client/src/settings/dotfiles.rs delete mode 100644 atuin-client/src/sync.rs delete mode 100644 atuin-client/src/utils.rs delete mode 100644 atuin-client/tests/data/xonsh-history.sqlite delete mode 100644 atuin-client/tests/data/xonsh/xonsh-82eafbf5-9f43-489a-80d2-61c7dc6ef542.json delete mode 100644 atuin-client/tests/data/xonsh/xonsh-de16af90-9148-4461-8df3-5b5659c6420d.json (limited to 'atuin-client') diff --git a/atuin-client/Cargo.toml b/atuin-client/Cargo.toml deleted file mode 100644 index c8ca74ae..00000000 --- a/atuin-client/Cargo.toml +++ /dev/null @@ -1,73 +0,0 @@ -[package] -name = "atuin-client" -edition = "2021" -description = "client library for atuin" - -rust-version = { workspace = true } -version = { workspace = true } -authors = { workspace = true } -license = { workspace = true } -homepage = { workspace = true } -repository = { workspace = true } - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[features] -default = ["sync"] -sync = ["urlencoding", "reqwest", "sha2", "hex"] -check-update = [] - -[dependencies] -atuin-common = { path = "../atuin-common", version = "18.2.0" } - -log = { workspace = true } -base64 = { workspace = true } -time = { workspace = true, features = ["macros", "formatting"] } -clap = { workspace = true } -eyre = { workspace = true } -directories = { workspace = true } -uuid = { workspace = true } -whoami = { workspace = true } -interim = { workspace = true } -config = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -parse_duration = "2.1.1" -async-trait = { workspace = true } -itertools = { workspace = true } -rand = { workspace = true } -shellexpand = "3" -sqlx = { workspace = true, features = ["sqlite", "regexp"] } -minspan = "0.1.1" -regex = "1.10.4" -serde_regex = "1.1.0" -fs-err = { workspace = true } -sql-builder = "3" -memchr = "2.5" -rmp = { version = "0.8.11" } -typed-builder = { workspace = true } -tokio = { workspace = true } -semver = { workspace = true } -thiserror = { workspace = true } -futures = "0.3" -crypto_secretbox = "0.1.1" -generic-array = { version = "0.14", features = ["serde"] } -serde_with = "3.5.1" - -# encryption -rusty_paseto = { version = "0.6.0", default-features = false } -rusty_paserk = { version = "0.3.0", default-features = false, features = [ - "v4", - "serde", -] } - -# sync -urlencoding = { version = "2.1.0", optional = true } -reqwest = { workspace = true, optional = true } -hex = { version = "0.4", optional = true } -sha2 = { version = "0.10", optional = true } -indicatif = "0.17.7" - -[dev-dependencies] -tokio = { version = "1", features = ["full"] } -pretty_assertions = { workspace = true } diff --git a/atuin-client/config.toml b/atuin-client/config.toml deleted file mode 100644 index 415fd441..00000000 --- a/atuin-client/config.toml +++ /dev/null @@ -1,210 +0,0 @@ -## where to store your database, default is your system data directory -## linux/mac: ~/.local/share/atuin/history.db -## windows: %USERPROFILE%/.local/share/atuin/history.db -# db_path = "~/.history.db" - -## where to store your encryption key, default is your system data directory -## linux/mac: ~/.local/share/atuin/key -## windows: %USERPROFILE%/.local/share/atuin/key -# key_path = "~/.key" - -## where to store your auth session token, default is your system data directory -## linux/mac: ~/.local/share/atuin/session -## windows: %USERPROFILE%/.local/share/atuin/session -# session_path = "~/.session" - -## date format used, either "us" or "uk" -# dialect = "us" - -## default timezone to use when displaying time -## either "l", "local" to use the system's current local timezone, or an offset -## from UTC in the format of "<+|->H[H][:M[M][:S[S]]]" -## for example: "+9", "-05", "+03:30", "-01:23:45", etc. -# timezone = "local" - -## enable or disable automatic sync -# auto_sync = true - -## enable or disable automatic update checks -# update_check = true - -## address of the sync server -# sync_address = "https://api.atuin.sh" - -## how often to sync history. note that this is only triggered when a command -## is ran, so sync intervals may well be longer -## set it to 0 to sync after every command -# sync_frequency = "10m" - -## which search mode to use -## possible values: prefix, fulltext, fuzzy, skim -# search_mode = "fuzzy" - -## which filter mode to use -## possible values: global, host, session, directory -# filter_mode = "global" - -## With workspace filtering enabled, Atuin will filter for commands executed -## in any directory within a git repository tree (default: false) -# workspaces = false - -## which filter mode to use when atuin is invoked from a shell up-key binding -## the accepted values are identical to those of "filter_mode" -## leave unspecified to use same mode set in "filter_mode" -# filter_mode_shell_up_key_binding = "global" - -## which search mode to use when atuin is invoked from a shell up-key binding -## the accepted values are identical to those of "search_mode" -## leave unspecified to use same mode set in "search_mode" -# search_mode_shell_up_key_binding = "fuzzy" - -## which style to use -## possible values: auto, full, compact -# style = "auto" - -## the maximum number of lines the interface should take up -## set it to 0 to always go full screen -# inline_height = 0 - -## Invert the UI - put the search bar at the top , Default to `false` -# invert = false - -## enable or disable showing a preview of the selected command -## useful when the command is longer than the terminal width and is cut off -# show_preview = false - -## enable or disable automatic preview. It shows a preview, if the command is -## longer than the width of the terminal. It respects max_preview_height. -## (default: true) -# show_preview_auto = true - -## what to do when the escape key is pressed when searching -## possible values: return-original, return-query -# exit_mode = "return-original" - -## possible values: emacs, subl -# word_jump_mode = "emacs" - -## characters that count as a part of a word -# word_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - -## number of context lines to show when scrolling by pages -# scroll_context_lines = 1 - -## use ctrl instead of alt as the shortcut modifier key for numerical UI shortcuts -## alt-0 .. alt-9 -# ctrl_n_shortcuts = false - -## default history list format - can also be specified with the --format arg -# history_format = "{time}\t{command}\t{duration}" - -## prevent commands matching any of these regexes from being written to history. -## Note that these regular expressions are unanchored, i.e. if they don't start -## with ^ or end with $, they'll match anywhere in the command. -## For details on the supported regular expression syntax, see -## https://docs.rs/regex/latest/regex/#syntax -# history_filter = [ -# "^secret-cmd", -# "^innocuous-cmd .*--secret=.+", -# ] - -## prevent commands run with cwd matching any of these regexes from being written -## to history. Note that these regular expressions are unanchored, i.e. if they don't -## start with ^ or end with $, they'll match anywhere in CWD. -## For details on the supported regular expression syntax, see -## https://docs.rs/regex/latest/regex/#syntax -# cwd_filter = [ -# "^/very/secret/area", -# ] - -## Configure the maximum height of the preview to show. -## Useful when you have long scripts in your history that you want to distinguish -## by more than the first few lines. -# max_preview_height = 4 - -## Configure whether or not to show the help row, which includes the current Atuin -## version (and whether an update is available), a keymap hint, and the total -## amount of commands in your history. -# show_help = true - -## Configure whether or not to show tabs for search and inspect -# show_tabs = true - -## Defaults to true. This matches history against a set of default regex, and will not save it if we get a match. Defaults include -## 1. AWS key id -## 2. Github pat (old and new) -## 3. Slack oauth tokens (bot, user) -## 4. Slack webhooks -## 5. Stripe live/test keys -# secrets_filter = true - -## Defaults to true. If enabled, upon hitting enter Atuin will immediately execute the command. Press tab to return to the shell and edit. -# This applies for new installs. Old installs will keep the old behaviour unless configured otherwise. -enter_accept = true - -## Defaults to "emacs". This specifies the keymap on the startup of `atuin -## search`. If this is set to "auto", the startup keymap mode in the Atuin -## search is automatically selected based on the shell's keymap where the -## keybinding is defined. If this is set to "emacs", "vim-insert", or -## "vim-normal", the startup keymap mode in the Atuin search is forced to be -## the specified one. -# keymap_mode = "auto" - -## Cursor style in each keymap mode. If specified, the cursor style is changed -## in entering the cursor shape. Available values are "default" and -## "{blink,steady}-{block,underline,bar}". -# keymap_cursor = { emacs = "blink-block", vim_insert = "blink-block", vim_normal = "steady-block" } - -# network_connect_timeout = 5 -# network_timeout = 5 - -## Timeout (in seconds) for acquiring a local database connection (sqlite) -# local_timeout = 5 - -## Set this to true and Atuin will minimize motion in the UI - timers will not update live, etc. -## Alternatively, set env NO_MOTION=true -# prefers_reduced_motion = false - -[stats] -## Set commands where we should consider the subcommand for statistics. Eg, kubectl get vs just kubectl -# common_subcommands = [ -# "apt", -# "cargo", -# "composer", -# "dnf", -# "docker", -# "git", -# "go", -# "ip", -# "kubectl", -# "nix", -# "nmcli", -# "npm", -# "pecl", -# "pnpm", -# "podman", -# "port", -# "systemctl", -# "tmux", -# "yarn", -# ] - -## Set commands that should be totally stripped and ignored from stats -# common_prefix = ["sudo"] - -## Set commands that will be completely ignored from stats -# ignored_commands = [ -# "cd", -# "ls", -# "vi" -# ] - -[keys] -# Defaults to true. If disabled, using the up/down key won't exit the TUI when scrolled past the first/last entry. -# scroll_exits = false - -[sync] -# Enable sync v2 by default -# This ensures that sync v2 is enabled for new installs only -# In a later release it will become the default across the board -records = true diff --git a/atuin-client/migrations/20210422143411_create_history.sql b/atuin-client/migrations/20210422143411_create_history.sql deleted file mode 100644 index 1f3f8686..00000000 --- a/atuin-client/migrations/20210422143411_create_history.sql +++ /dev/null @@ -1,16 +0,0 @@ --- Add migration script here -create table if not exists history ( - id text primary key, - timestamp integer not null, - duration integer not null, - exit integer not null, - command text not null, - cwd text not null, - session text not null, - hostname text not null, - - unique(timestamp, cwd, command) -); - -create index if not exists idx_history_timestamp on history(timestamp); -create index if not exists idx_history_command on history(command); diff --git a/atuin-client/migrations/20220505083406_create-events.sql b/atuin-client/migrations/20220505083406_create-events.sql deleted file mode 100644 index f6cafeba..00000000 --- a/atuin-client/migrations/20220505083406_create-events.sql +++ /dev/null @@ -1,11 +0,0 @@ -create table if not exists events ( - id text primary key, - timestamp integer not null, - hostname text not null, - event_type text not null, - - history_id text not null -); - --- Ensure there is only ever one of each event type per history item -create unique index history_event_idx ON events(event_type, history_id); diff --git a/atuin-client/migrations/20220806155627_interactive_search_index.sql b/atuin-client/migrations/20220806155627_interactive_search_index.sql deleted file mode 100644 index b5770e62..00000000 --- a/atuin-client/migrations/20220806155627_interactive_search_index.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Interactive search filters by command then by the max(timestamp) for that --- command. Create an index that covers those -create index if not exists idx_history_command_timestamp on history( - command, - timestamp -); diff --git a/atuin-client/migrations/20230315220114_drop-events.sql b/atuin-client/migrations/20230315220114_drop-events.sql deleted file mode 100644 index fe3cae17..00000000 --- a/atuin-client/migrations/20230315220114_drop-events.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Add migration script here -drop table events; diff --git a/atuin-client/migrations/20230319185725_deleted_at.sql b/atuin-client/migrations/20230319185725_deleted_at.sql deleted file mode 100644 index 6c422abc..00000000 --- a/atuin-client/migrations/20230319185725_deleted_at.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Add migration script here -alter table history add column deleted_at integer; diff --git a/atuin-client/record-migrations/20230531212437_create-records.sql b/atuin-client/record-migrations/20230531212437_create-records.sql deleted file mode 100644 index 4f4b304a..00000000 --- a/atuin-client/record-migrations/20230531212437_create-records.sql +++ /dev/null @@ -1,16 +0,0 @@ --- Add migration script here -create table if not exists records ( - id text primary key, - parent text unique, -- null if this is the first one - host text not null, - - timestamp integer not null, - tag text not null, - version text not null, - data blob not null, - cek blob not null -); - -create index host_idx on records (host); -create index tag_idx on records (tag); -create index host_tag_idx on records (host, tag); diff --git a/atuin-client/record-migrations/20231127090831_create-store.sql b/atuin-client/record-migrations/20231127090831_create-store.sql deleted file mode 100644 index 53d78860..00000000 --- a/atuin-client/record-migrations/20231127090831_create-store.sql +++ /dev/null @@ -1,15 +0,0 @@ --- Add migration script here -create table if not exists store ( - id text primary key, -- globally unique ID - - idx integer, -- incrementing integer ID unique per (host, tag) - host text not null, -- references the host row - tag text not null, - - timestamp integer not null, - version text not null, - data blob not null, - cek blob not null -); - -create unique index record_uniq ON store(host, tag, idx); diff --git a/atuin-client/src/api_client.rs b/atuin-client/src/api_client.rs deleted file mode 100644 index f31a796e..00000000 --- a/atuin-client/src/api_client.rs +++ /dev/null @@ -1,415 +0,0 @@ -use std::collections::HashMap; -use std::env; -use std::time::Duration; - -use eyre::{bail, Result}; -use reqwest::{ - header::{HeaderMap, AUTHORIZATION, USER_AGENT}, - Response, StatusCode, Url, -}; - -use atuin_common::{ - api::{ - AddHistoryRequest, ChangePasswordRequest, CountResponse, DeleteHistoryRequest, - ErrorResponse, LoginRequest, LoginResponse, MeResponse, RegisterResponse, StatusResponse, - SyncHistoryResponse, - }, - record::RecordStatus, -}; -use atuin_common::{ - api::{ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ATUIN_VERSION}, - record::{EncryptedData, HostId, Record, RecordIdx}, -}; - -use semver::Version; -use time::format_description::well_known::Rfc3339; -use time::OffsetDateTime; - -use crate::{history::History, sync::hash_str, utils::get_host_user}; - -static APP_USER_AGENT: &str = concat!("atuin/", env!("CARGO_PKG_VERSION"),); - -pub struct Client<'a> { - sync_addr: &'a str, - client: reqwest::Client, -} - -pub async fn register( - address: &str, - username: &str, - email: &str, - password: &str, -) -> Result { - let mut map = HashMap::new(); - map.insert("username", username); - map.insert("email", email); - map.insert("password", password); - - let url = format!("{address}/user/{username}"); - let resp = reqwest::get(url).await?; - - if resp.status().is_success() { - bail!("username already in use"); - } - - let url = format!("{address}/register"); - let client = reqwest::Client::new(); - let resp = client - .post(url) - .header(USER_AGENT, APP_USER_AGENT) - .header(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION) - .json(&map) - .send() - .await?; - - if !ensure_version(&resp)? { - bail!("could not register user due to version mismatch"); - } - - if !resp.status().is_success() { - let error = resp.json::().await?; - bail!("failed to register user: {}", error.reason); - } - - let session = resp.json::().await?; - Ok(session) -} - -pub async fn login(address: &str, req: LoginRequest) -> Result { - let url = format!("{address}/login"); - let client = reqwest::Client::new(); - - let resp = client - .post(url) - .header(USER_AGENT, APP_USER_AGENT) - .json(&req) - .send() - .await?; - - if !ensure_version(&resp)? { - bail!("could not login due to version mismatch"); - } - - if resp.status() != reqwest::StatusCode::OK { - let error = resp.json::().await?; - bail!("invalid login details: {}", error.reason); - } - - let session = resp.json::().await?; - Ok(session) -} - -#[cfg(feature = "check-update")] -pub async fn latest_version() -> Result { - use atuin_common::api::IndexResponse; - - let url = "https://api.atuin.sh"; - let client = reqwest::Client::new(); - - let resp = client - .get(url) - .header(USER_AGENT, APP_USER_AGENT) - .send() - .await?; - - if resp.status() != reqwest::StatusCode::OK { - let error = resp.json::().await?; - bail!("failed to check latest version: {}", error.reason); - } - - let index = resp.json::().await?; - let version = Version::parse(index.version.as_str())?; - - Ok(version) -} - -pub fn ensure_version(response: &Response) -> Result { - 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 { - // if there is no version header, then the newest this server can possibly be is 17.1.0 - Version::parse("17.1.0") - }?; - - // 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 { - let status = resp.status(); - - if status == StatusCode::SERVICE_UNAVAILABLE { - bail!( - "Service unavailable: check https://status.atuin.sh (or get in touch with your host)" - ); - } - - if !status.is_success() { - if let Ok(error) = resp.json::().await { - let reason = error.reason; - - if status.is_client_error() { - bail!("Could not fetch history, client error {status}: {reason}.") - } - - bail!("There was an error with the atuin sync service, server error {status}: {reason}.\nIf the problem persists, contact the host") - } - - bail!("There was an error with the atuin sync service: Status {status:?}.\nIf the problem persists, contact the host") - } - - Ok(resp) -} - -impl<'a> Client<'a> { - pub fn new( - sync_addr: &'a str, - session_token: &str, - connect_timeout: u64, - timeout: u64, - ) -> Result { - let mut headers = HeaderMap::new(); - headers.insert(AUTHORIZATION, format!("Token {session_token}").parse()?); - - // used for semver server check - headers.insert(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION.parse()?); - - Ok(Client { - sync_addr, - client: 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 async fn count(&self) -> Result { - let url = format!("{}/sync/count", self.sync_addr); - let url = Url::parse(url.as_str())?; - - let resp = self.client.get(url).send().await?; - let resp = handle_resp_error(resp).await?; - - if !ensure_version(&resp)? { - bail!("could not sync due to version mismatch"); - } - - if resp.status() != StatusCode::OK { - bail!("failed to get count (are you logged in?)"); - } - - let count = resp.json::().await?; - - Ok(count.count) - } - - pub async fn status(&self) -> Result { - let url = format!("{}/sync/status", self.sync_addr); - let url = Url::parse(url.as_str())?; - - let resp = self.client.get(url).send().await?; - let resp = handle_resp_error(resp).await?; - - if !ensure_version(&resp)? { - bail!("could not sync due to version mismatch"); - } - - let status = resp.json::().await?; - - Ok(status) - } - - pub async fn me(&self) -> Result { - let url = format!("{}/api/v0/me", self.sync_addr); - let url = Url::parse(url.as_str())?; - - let resp = self.client.get(url).send().await?; - let resp = handle_resp_error(resp).await?; - - let status = resp.json::().await?; - - Ok(status) - } - - pub async fn get_history( - &self, - sync_ts: OffsetDateTime, - history_ts: OffsetDateTime, - host: Option, - ) -> Result { - let host = host.unwrap_or_else(|| hash_str(&get_host_user())); - - let url = format!( - "{}/sync/history?sync_ts={}&history_ts={}&host={}", - self.sync_addr, - urlencoding::encode(sync_ts.format(&Rfc3339)?.as_str()), - urlencoding::encode(history_ts.format(&Rfc3339)?.as_str()), - host, - ); - - let resp = self.client.get(url).send().await?; - let resp = handle_resp_error(resp).await?; - - let history = resp.json::().await?; - Ok(history) - } - - pub async fn post_history(&self, history: &[AddHistoryRequest]) -> Result<()> { - let url = format!("{}/history", self.sync_addr); - let url = Url::parse(url.as_str())?; - - let resp = self.client.post(url).json(history).send().await?; - handle_resp_error(resp).await?; - - Ok(()) - } - - pub async fn delete_history(&self, h: History) -> Result<()> { - let url = format!("{}/history", self.sync_addr); - let url = Url::parse(url.as_str())?; - - let resp = self - .client - .delete(url) - .json(&DeleteHistoryRequest { - client_id: h.id.to_string(), - }) - .send() - .await?; - - handle_resp_error(resp).await?; - - Ok(()) - } - - pub async fn delete_store(&self) -> Result<()> { - let url = format!("{}/api/v0/store", self.sync_addr); - let url = Url::parse(url.as_str())?; - - let resp = self.client.delete(url).send().await?; - - handle_resp_error(resp).await?; - - Ok(()) - } - - pub async fn post_records(&self, records: &[Record]) -> Result<()> { - let url = format!("{}/api/v0/record", self.sync_addr); - let url = Url::parse(url.as_str())?; - - debug!("uploading {} records to {url}", records.len()); - - let resp = self.client.post(url).json(records).send().await?; - handle_resp_error(resp).await?; - - Ok(()) - } - - pub async fn next_records( - &self, - host: HostId, - tag: String, - start: RecordIdx, - count: u64, - ) -> Result>> { - debug!( - "fetching record/s from host {}/{}/{}", - host.0.to_string(), - tag, - start - ); - - let url = format!( - "{}/api/v0/record/next?host={}&tag={}&count={}&start={}", - self.sync_addr, host.0, tag, count, start - ); - - let url = Url::parse(url.as_str())?; - - let resp = self.client.get(url).send().await?; - let resp = handle_resp_error(resp).await?; - - let records = resp.json::>>().await?; - - Ok(records) - } - - pub async fn record_status(&self) -> Result { - let url = format!("{}/api/v0/record", self.sync_addr); - let url = Url::parse(url.as_str())?; - - let resp = self.client.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) - } - - pub async fn delete(&self) -> Result<()> { - let url = format!("{}/account", self.sync_addr); - let url = Url::parse(url.as_str())?; - - let resp = self.client.delete(url).send().await?; - - if resp.status() == 403 { - bail!("invalid login details"); - } else if resp.status() == 200 { - Ok(()) - } else { - bail!("Unknown error"); - } - } - - pub async fn change_password( - &self, - current_password: String, - new_password: String, - ) -> Result<()> { - let url = format!("{}/account/password", self.sync_addr); - let url = Url::parse(url.as_str())?; - - let resp = self - .client - .patch(url) - .json(&ChangePasswordRequest { - current_password, - new_password, - }) - .send() - .await?; - - dbg!(&resp); - - if resp.status() == 401 { - bail!("current password is incorrect") - } else if resp.status() == 403 { - bail!("invalid login details"); - } else if resp.status() == 200 { - Ok(()) - } else { - bail!("Unknown error"); - } - } -} diff --git a/atuin-client/src/database.rs b/atuin-client/src/database.rs deleted file mode 100644 index 7faa3802..00000000 --- a/atuin-client/src/database.rs +++ /dev/null @@ -1,1128 +0,0 @@ -use std::{ - borrow::Cow, - env, - path::{Path, PathBuf}, - str::FromStr, - time::Duration, -}; - -use async_trait::async_trait; -use atuin_common::utils; -use fs_err as fs; -use itertools::Itertools; -use rand::{distributions::Alphanumeric, Rng}; -use sql_builder::{bind::Bind, esc, quote, SqlBuilder, SqlName}; -use sqlx::{ - sqlite::{ - SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteRow, - SqliteSynchronous, - }, - Result, Row, -}; -use time::OffsetDateTime; - -use crate::{ - history::{HistoryId, HistoryStats}, - utils::get_host_user, -}; - -use super::{ - history::History, - ordering, - settings::{FilterMode, SearchMode, Settings}, -}; - -pub struct Context { - pub session: String, - pub cwd: String, - pub hostname: String, - pub host_id: String, - pub git_root: Option, -} - -#[derive(Default, Clone)] -pub struct OptFilters { - pub exit: Option, - pub exclude_exit: Option, - pub cwd: Option, - pub exclude_cwd: Option, - pub before: Option, - pub after: Option, - pub limit: Option, - pub offset: Option, - pub reverse: bool, -} - -pub fn current_context() -> Context { - let Ok(session) = env::var("ATUIN_SESSION") else { - eprintln!("ERROR: Failed to find $ATUIN_SESSION in the environment. Check that you have correctly set up your shell."); - std::process::exit(1); - }; - let hostname = get_host_user(); - let cwd = utils::get_current_dir(); - let host_id = Settings::host_id().expect("failed to load host ID"); - let git_root = utils::in_git_repo(cwd.as_str()); - - Context { - session, - hostname, - cwd, - git_root, - host_id: host_id.0.as_simple().to_string(), - } -} - -#[async_trait] -pub trait Database: Send + Sync + 'static { - async fn save(&self, h: &History) -> Result<()>; - async fn save_bulk(&self, h: &[History]) -> Result<()>; - - async fn load(&self, id: &str) -> Result>; - async fn list( - &self, - filters: &[FilterMode], - context: &Context, - max: Option, - unique: bool, - include_deleted: bool, - ) -> Result>; - async fn range(&self, from: OffsetDateTime, to: OffsetDateTime) -> Result>; - - async fn update(&self, h: &History) -> Result<()>; - async fn history_count(&self, include_deleted: bool) -> Result; - - async fn last(&self) -> Result>; - async fn before(&self, timestamp: OffsetDateTime, count: i64) -> Result>; - - async fn delete(&self, h: History) -> Result<()>; - async fn delete_rows(&self, ids: &[HistoryId]) -> Result<()>; - async fn deleted(&self) -> Result>; - - // 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" - #[allow(clippy::too_many_arguments)] - async fn search( - &self, - search_mode: SearchMode, - filter: FilterMode, - context: &Context, - query: &str, - filter_options: OptFilters, - ) -> Result>; - - async fn query_history(&self, query: &str) -> Result>; - - async fn all_with_count(&self) -> Result>; - - async fn stats(&self, h: &History) -> Result; -} - -// Intended for use on a developer machine and not a sync server. -// TODO: implement IntoIterator -pub struct Sqlite { - pub pool: SqlitePool, -} - -impl Sqlite { - pub async fn new(path: impl AsRef, timeout: f64) -> Result { - let path = path.as_ref(); - debug!("opening sqlite database at {:?}", path); - - let create = !path.exists(); - if create { - if let Some(dir) = path.parent() { - fs::create_dir_all(dir)?; - } - } - - let opts = SqliteConnectOptions::from_str(path.as_os_str().to_str().unwrap())? - .journal_mode(SqliteJournalMode::Wal) - .optimize_on_close(true, None) - .synchronous(SqliteSynchronous::Normal) - .with_regexp() - .create_if_missing(true); - - let pool = SqlitePoolOptions::new() - .acquire_timeout(Duration::from_secs_f64(timeout)) - .connect_with(opts) - .await?; - - Self::setup_db(&pool).await?; - - Ok(Self { pool }) - } - - async fn setup_db(pool: &SqlitePool) -> Result<()> { - debug!("running sqlite database setup"); - - sqlx::migrate!("./migrations").run(pool).await?; - - Ok(()) - } - - 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, deleted_at) - values(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - ) - .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.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(()) - } - - fn query_history(row: SqliteRow) -> History { - let deleted_at: Option = row.get("deleted_at"); - - History::from_db() - .id(row.get("id")) - .timestamp( - OffsetDateTime::from_unix_timestamp_nanos(row.get::("timestamp") as i128) - .unwrap(), - ) - .duration(row.get("duration")) - .exit(row.get("exit")) - .command(row.get("command")) - .cwd(row.get("cwd")) - .session(row.get("session")) - .hostname(row.get("hostname")) - .deleted_at( - deleted_at.and_then(|t| OffsetDateTime::from_unix_timestamp_nanos(t as i128).ok()), - ) - .build() - .into() - } -} - -#[async_trait] -impl Database for Sqlite { - 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(()) - } - - 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(()) - } - - async fn load(&self, id: &str) -> Result> { - debug!("loading history item {}", id); - - let res = sqlx::query("select * from history where id = ?1") - .bind(id) - .map(Self::query_history) - .fetch_optional(&self.pool) - .await?; - - Ok(res) - } - - async fn update(&self, h: &History) -> Result<()> { - debug!("updating sqlite history"); - - sqlx::query( - "update history - set timestamp = ?2, duration = ?3, exit = ?4, command = ?5, cwd = ?6, session = ?7, hostname = ?8, deleted_at = ?9 - where id = ?1", - ) - .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.deleted_at.map(|t|t.unix_timestamp_nanos() as i64)) - .execute(&self.pool) - .await?; - - Ok(()) - } - - // make a unique list, that only shows the *newest* version of things - async fn list( - &self, - filters: &[FilterMode], - context: &Context, - max: Option, - unique: bool, - include_deleted: bool, - ) -> Result> { - 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 = if let Some(git_root) = context.git_root.clone() { - git_root.to_str().unwrap_or("/").to_string() - } else { - context.cwd.clone() - }; - - 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::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) - .fetch_all(&self.pool) - .await?; - - Ok(res) - } - - async fn range(&self, from: OffsetDateTime, to: OffsetDateTime) -> Result> { - 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) - .fetch_all(&self.pool) - .await?; - - Ok(res) - } - - async fn last(&self) -> Result> { - let res = sqlx::query( - "select * from history where duration >= 0 order by timestamp desc limit 1", - ) - .map(Self::query_history) - .fetch_optional(&self.pool) - .await?; - - Ok(res) - } - - async fn before(&self, timestamp: OffsetDateTime, count: i64) -> Result> { - let res = sqlx::query( - "select * from history where timestamp < ?1 order by timestamp desc limit ?2", - ) - .bind(timestamp.unix_timestamp_nanos() as i64) - .bind(count) - .map(Self::query_history) - .fetch_all(&self.pool) - .await?; - - Ok(res) - } - - async fn deleted(&self) -> Result> { - let res = sqlx::query("select * from history where deleted_at is not null") - .map(Self::query_history) - .fetch_all(&self.pool) - .await?; - - Ok(res) - } - - async fn history_count(&self, include_deleted: bool) -> Result { - 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) - } - - async fn search( - &self, - search_mode: SearchMode, - filter: FilterMode, - context: &Context, - query: &str, - filter_options: OptFilters, - ) -> Result> { - let mut sql = SqlBuilder::select_from("history"); - - 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 = if let Some(git_root) = context.git_root.clone() { - git_root.to_str().unwrap_or("/").to_string() - } else { - context.cwd.clone() - }; - - 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::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(); - match search_mode { - SearchMode::Prefix => sql.and_where_like_left("command", query.replace('*', "%")), - _ => { - let mut is_or = false; - let mut regex = None; - for part in query.split_inclusive(' ') { - let query_part: Cow = match (&mut regex, part.starts_with("r/")) { - (None, false) => { - if part.trim_end().is_empty() { - continue; - } - Cow::Owned(part.trim_end().replace('*', "%")) // allow wildcard char - } - (None, true) => { - if part[2..].trim_end().ends_with('/') { - let end_pos = part.trim_end().len() - 1; - regexes.push(String::from(&part[2..end_pos])); - } else { - regex = Some(String::from(&part[2..])); - } - continue; - } - (Some(r), _) => { - if part.trim_end().ends_with('/') { - let end_pos = part.trim_end().len() - 1; - r.push_str(&part.trim_end()[..end_pos]); - regexes.push(regex.take().unwrap()); - } else { - r.push_str(part); - } - continue; - } - }; - - // TODO smart case mode could be made configurable like in fzf - let (is_glob, glob) = if query_part.contains(char::is_uppercase) { - (true, "*") - } else { - (false, "%") - }; - - let (is_inverse, query_part) = match query_part.strip_prefix('!') { - Some(stripped) => (true, Cow::Borrowed(stripped)), - None => (false, query_part), - }; - - #[allow(clippy::if_same_then_else)] - let param = if query_part == "|" { - if !is_or { - is_or = true; - continue; - } else { - format!("{glob}|{glob}") - } - } else if let Some(term) = query_part.strip_prefix('^') { - format!("{term}{glob}") - } else if let Some(term) = query_part.strip_suffix('$') { - format!("{glob}{term}") - } else if let Some(term) = query_part.strip_prefix('\'') { - format!("{glob}{term}{glob}") - } else if is_inverse { - format!("{glob}{query_part}{glob}") - } else if search_mode == SearchMode::FullText { - format!("{glob}{query_part}{glob}") - } else { - query_part.split("").join(glob) - }; - - sql.fuzzy_condition("command", param, is_inverse, is_glob, is_or); - is_or = false; - } - if let Some(r) = regex { - regexes.push(r); - } - - &mut sql - } - }; - - for regex in regexes { - sql.and_where("command regexp ?".bind(®ex)); - } - - 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) - .fetch_all(&self.pool) - .await?; - - Ok(ordering::reorder_fuzzy(search_mode, orig_query, res)) - } - - async fn query_history(&self, query: &str) -> Result> { - let res = sqlx::query(query) - .map(Self::query_history) - .fetch_all(&self.pool) - .await?; - - Ok(res) - } - - async fn all_with_count(&self) -> Result> { - 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", - "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(row), count) - }) - .fetch_all(&self.pool) - .await?; - - Ok(res) - } - - // deleted_at doesn't mean the actual time that the user deleted it, - // but the time that the system marks it as deleted - async fn delete(&self, mut h: History) -> Result<()> { - let now = OffsetDateTime::now_utc(); - h.command = rand::thread_rng() - .sample_iter(&Alphanumeric) - .take(32) - .map(char::from) - .collect(); // overwrite with random string - h.deleted_at = Some(now); // delete it - - self.update(&h).await?; // save it - - Ok(()) - } - - 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(()) - } - - async fn stats(&self, h: &History) -> Result { - // 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) - .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) - .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, - }) - } -} - -#[cfg(test)] -mod test { - use super::*; - use std::time::{Duration, Instant}; - - async fn assert_search_eq<'a>( - db: &impl Database, - mode: SearchMode, - filter_mode: FilterMode, - query: &str, - expected: usize, - ) -> Result> { - 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::>() - ); - Ok(results) - } - - async fn assert_search_commands( - db: &impl Database, - 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 impl Database, 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 = Sqlite::new("sqlite::memory:", 0.1).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 = Sqlite::new("sqlite::memory:", 0.1).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 = Sqlite::new("sqlite::memory:", 0.1).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 = Sqlite::new("sqlite::memory:", 0.1).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")] - 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 = Sqlite::new("sqlite::memory:", 0.1).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)); - } -} - -trait SqlBuilderExt { - fn fuzzy_condition( - &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( - &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) - } - } -} diff --git a/atuin-client/src/encryption.rs b/atuin-client/src/encryption.rs deleted file mode 100644 index 50aacc24..00000000 --- a/atuin-client/src/encryption.rs +++ /dev/null @@ -1,430 +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::*, path::PathBuf}; - -use base64::prelude::{Engine, BASE64_STANDARD}; -pub use crypto_secretbox::Key; -use crypto_secretbox::{ - aead::{Nonce, OsRng}, - AeadCore, AeadInPlace, KeyInit, XSalsa20Poly1305, -}; -use eyre::{bail, ensure, eyre, Context, Result}; -use fs_err as fs; -use rmp::{decode::Bytes, Marker}; -use serde::{Deserialize, Serialize}; -use time::{format_description::well_known::Rfc3339, macros::format_description, OffsetDateTime}; - -use crate::{history::History, settings::Settings}; - -#[derive(Debug, Serialize, Deserialize)] -pub struct EncryptedHistory { - pub ciphertext: Vec, - pub nonce: Nonce, -} - -pub fn generate_encoded_key() -> Result<(Key, String)> { - let key = XSalsa20Poly1305::generate_key(&mut OsRng); - let encoded = encode_key(&key)?; - - Ok((key, encoded)) -} - -pub fn new_key(settings: &Settings) -> Result { - let path = settings.key_path.as_str(); - let path = PathBuf::from(path); - - if path.exists() { - bail!("key already exists! cannot overwrite"); - } - - let (key, encoded) = generate_encoded_key()?; - - let mut file = fs::File::create(path)?; - file.write_all(encoded.as_bytes())?; - - Ok(key) -} - -// Loads the secret key, will create + save if it doesn't exist -pub fn load_key(settings: &Settings) -> Result { - let path = settings.key_path.as_str(); - - let key = if PathBuf::from(path).exists() { - let key = fs_err::read_to_string(path)?; - decode_key(key)? - } else { - new_key(settings)? - }; - - Ok(key) -} - -pub fn encode_key(key: &Key) -> Result { - 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, *b as u64) - .wrap_err("could not encode key to message pack")?; - } - let buf = BASE64_STANDARD.encode(buf); - - Ok(buf) -} - -pub fn decode_key(key: String) -> Result { - 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 - match <[u8; 32]>::try_from(&*buf) { - Ok(key) => Ok(key.into()), - Err(_) => { - let mut bytes = rmp::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 = rmp::decode::read_int(&mut bytes).map_err(|err| eyre!("{err:?}"))?; - } - Ok(key) - } - _ => bail!("could not decode encryption key"), - } - } - } -} - -pub fn encrypt(history: &History, key: &Key) -> Result { - // serialize with msgpack - let mut buf = encode(history)?; - - let nonce = XSalsa20Poly1305::generate_nonce(&mut OsRng); - XSalsa20Poly1305::new(key) - .encrypt_in_place(&nonce, &[], &mut buf) - .map_err(|_| eyre!("could not encrypt"))?; - - Ok(EncryptedHistory { - ciphertext: buf, - nonce, - }) -} - -pub fn decrypt(mut encrypted_history: EncryptedHistory, key: &Key) -> Result { - XSalsa20Poly1305::new(key) - .decrypt_in_place( - &encrypted_history.nonce, - &[], - &mut encrypted_history.ciphertext, - ) - .map_err(|_| eyre!("could not encrypt"))?; - let plaintext = encrypted_history.ciphertext; - - let history = decode(&plaintext)?; - - Ok(history) -} - -fn format_rfc3339(ts: OffsetDateTime) -> Result { - // horrible hack. chrono AutoSI limits to 0, 3, 6, or 9 decimal places for nanoseconds. - // time does not have this functionality. - static PARTIAL_RFC3339_0: &[time::format_description::FormatItem<'static>] = - format_description!("[year]-[month]-[day]T[hour]:[minute]:[second]Z"); - static PARTIAL_RFC3339_3: &[time::format_description::FormatItem<'static>] = - format_description!("[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z"); - static PARTIAL_RFC3339_6: &[time::format_description::FormatItem<'static>] = - format_description!("[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:6]Z"); - static PARTIAL_RFC3339_9: &[time::format_description::FormatItem<'static>] = - format_description!("[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:9]Z"); - - let fmt = match ts.nanosecond() { - 0 => PARTIAL_RFC3339_0, - ns if ns % 1_000_000 == 0 => PARTIAL_RFC3339_3, - ns if ns % 1_000 == 0 => PARTIAL_RFC3339_6, - _ => PARTIAL_RFC3339_9, - }; - - Ok(ts.format(fmt)?) -} - -fn encode(h: &History) -> Result> { - use rmp::encode; - - let mut output = vec![]; - // INFO: ensure this is updated when adding new fields - encode::write_array_len(&mut output, 9)?; - - encode::write_str(&mut output, &h.id.0)?; - encode::write_str(&mut output, &(format_rfc3339(h.timestamp)?))?; - encode::write_sint(&mut output, h.duration)?; - encode::write_sint(&mut output, h.exit)?; - encode::write_str(&mut output, &h.command)?; - encode::write_str(&mut output, &h.cwd)?; - encode::write_str(&mut output, &h.session)?; - encode::write_str(&mut output, &h.hostname)?; - match h.deleted_at { - Some(d) => encode::write_str(&mut output, &format_rfc3339(d)?)?, - None => encode::write_nil(&mut output)?, - } - - Ok(output) -} - -fn decode(bytes: &[u8]) -> Result { - use rmp::decode::{self, DecodeStringError}; - - let mut bytes = Bytes::new(bytes); - - let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?; - if nfields < 8 { - bail!("malformed decrypted history") - } - if nfields > 9 { - bail!("cannot decrypt history from a newer version of atuin"); - } - - let bytes = bytes.remaining_slice(); - let (id, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?; - let (timestamp, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?; - - let mut bytes = Bytes::new(bytes); - 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)?; - - // if we have more fields, try and get the deleted_at - let mut deleted_at = None; - let mut bytes = bytes; - if nfields > 8 { - bytes = match decode::read_str_from_slice(bytes) { - Ok((d, b)) => { - deleted_at = Some(d); - b - } - // we accept null here - Err(DecodeStringError::TypeMismatch(Marker::Null)) => { - // consume the null marker - let mut c = Bytes::new(bytes); - decode::read_nil(&mut c).map_err(error_report)?; - c.remaining_slice() - } - Err(err) => return Err(error_report(err)), - }; - } - - if !bytes.is_empty() { - bail!("trailing bytes in encoded history. malformed") - } - - Ok(History { - id: id.to_owned().into(), - timestamp: OffsetDateTime::parse(timestamp, &Rfc3339)?, - duration, - exit, - command: command.to_owned(), - cwd: cwd.to_owned(), - session: session.to_owned(), - hostname: hostname.to_owned(), - deleted_at: deleted_at - .map(|t| OffsetDateTime::parse(t, &Rfc3339)) - .transpose()?, - }) -} - -fn error_report(err: E) -> eyre::Report { - eyre!("{err:?}") -} - -#[cfg(test)] -mod test { - use crypto_secretbox::{aead::OsRng, KeyInit, XSalsa20Poly1305}; - use pretty_assertions::assert_eq; - use time::{macros::datetime, OffsetDateTime}; - - use crate::history::History; - - use super::{decode, decrypt, encode, encrypt}; - - #[test] - fn test_encrypt_decrypt() { - let key1 = XSalsa20Poly1305::generate_key(&mut OsRng); - let key2 = XSalsa20Poly1305::generate_key(&mut OsRng); - - let history = History::from_db() - .id("1".into()) - .timestamp(OffsetDateTime::now_utc()) - .command("ls".into()) - .cwd("/home/ellie".into()) - .exit(0) - .duration(1) - .session("beep boop".into()) - .hostname("booop".into()) - .deleted_at(None) - .build() - .into(); - - let e1 = encrypt(&history, &key1).unwrap(); - let e2 = encrypt(&history, &key2).unwrap(); - - assert_ne!(e1.ciphertext, e2.ciphertext); - assert_ne!(e1.nonce, e2.nonce); - - // test decryption works - // this should pass - match decrypt(e1, &key1) { - Err(e) => panic!("failed to decrypt, got {}", e), - Ok(h) => assert_eq!(h, history), - }; - - // this should err - let _ = decrypt(e2, &key1).expect_err("expected an error decrypting with invalid key"); - } - - #[test] - fn test_decode() { - let bytes = [ - 0x99, 0xD9, 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, 187, 50, 48, 50, 51, 45, - 48, 53, 45, 50, 56, 84, 49, 56, 58, 51, 53, 58, 52, 48, 46, 54, 51, 51, 56, 55, 50, 90, - 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 history = History { - id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(), - timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00), - duration: 49206000, - 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(), - deleted_at: None, - }; - - let h = decode(&bytes).unwrap(); - assert_eq!(history, h); - - let b = encode(&h).unwrap(); - assert_eq!(&bytes, &*b); - } - - #[test] - fn test_decode_deleted() { - let history = History { - id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(), - timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00), - duration: 49206000, - 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(), - deleted_at: Some(datetime!(2023-05-28 18:35:40.633872 +00:00)), - }; - - let b = encode(&history).unwrap(); - let h = decode(&b).unwrap(); - assert_eq!(history, h); - } - - #[test] - fn test_decode_old() { - let bytes = [ - 0x98, 0xD9, 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, 187, 50, 48, 50, 51, 45, - 48, 53, 45, 50, 56, 84, 49, 56, 58, 51, 53, 58, 52, 48, 46, 54, 51, 51, 56, 55, 50, 90, - 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, - ]; - let history = History { - id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(), - timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00), - duration: 49206000, - 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(), - deleted_at: None, - }; - - let h = decode(&bytes).unwrap(); - assert_eq!(history, h); - } - - #[test] - fn key_encodings() { - use super::{decode_key, encode_key, 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.to_owned()).expect(k), key); - } - } -} diff --git a/atuin-client/src/history.rs b/atuin-client/src/history.rs deleted file mode 100644 index 1b590e88..00000000 --- a/atuin-client/src/history.rs +++ /dev/null @@ -1,517 +0,0 @@ -use core::fmt::Formatter; -use rmp::decode::ValueReadError; -use rmp::{decode::Bytes, Marker}; -use std::env; -use std::fmt::Display; - -use atuin_common::record::DecryptedData; -use atuin_common::utils::uuid_v7; - -use eyre::{bail, eyre, Result}; -use regex::RegexSet; - -use crate::utils::get_host_user; -use crate::{secrets::SECRET_PATTERNS, settings::Settings}; -use time::OffsetDateTime; - -mod builder; -pub mod store; - -const HISTORY_VERSION: &str = "v0"; -pub const HISTORY_TAG: &str = "history"; - -#[derive(Clone, Debug, Eq, PartialEq, Hash)] -pub struct HistoryId(pub String); - -impl Display for HistoryId { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl From for HistoryId { - fn from(s: String) -> Self { - Self(s) - } -} - -/// 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 should be added to `encryption::{encode, decode}` in a backwards -// compatible way. (eg sensible defaults and updating the nfields parameter) -#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)] -pub struct History { - /// A client-generated ID, used to identify the entry when syncing. - /// - /// Stored as `client_id` in the database. - pub id: HistoryId, - /// When the command was run. - pub timestamp: OffsetDateTime, - /// How long the command took to run. - pub duration: i64, - /// The exit code of the command. - pub exit: i64, - /// The command that was run. - pub command: String, - /// The current working directory when the command was run. - pub cwd: String, - /// The session ID, associated with a terminal session. - pub session: String, - /// The hostname of the machine the command was run on. - pub hostname: String, - /// Timestamp, which is set when the entry is deleted, allowing a soft delete. - pub deleted_at: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)] -pub struct HistoryStats { - /// The command that was ran after this one in the session - pub next: Option, - /// - /// The command that was ran before this one in the session - pub previous: Option, - - /// How many times has this command been ran? - pub total: u64, - - pub average_duration: u64, - - pub exits: Vec<(i64, i64)>, - - pub day_of_week: Vec<(String, i64)>, - - pub duration_over_time: Vec<(String, i64)>, -} - -impl History { - #[allow(clippy::too_many_arguments)] - fn new( - timestamp: OffsetDateTime, - command: String, - cwd: String, - exit: i64, - duration: i64, - session: Option, - hostname: Option, - deleted_at: Option, - ) -> Self { - let session = session - .or_else(|| env::var("ATUIN_SESSION").ok()) - .unwrap_or_else(|| uuid_v7().as_simple().to_string()); - let hostname = hostname.unwrap_or_else(get_host_user); - - Self { - id: uuid_v7().as_simple().to_string().into(), - timestamp, - command, - cwd, - exit, - duration, - session, - hostname, - deleted_at, - } - } - - pub fn serialize(&self) -> Result { - // 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, 0)?; - // INFO: ensure this is updated when adding new fields - encode::write_array_len(&mut output, 9)?; - - 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)?, - } - - Ok(DecryptedData(output)) - } - - fn deserialize_v0(bytes: &[u8]) -> Result { - use rmp::decode; - - fn error_report(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 != 0 { - 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)?; - - // if we have more fields, try and get the deleted_at - 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(History { - id: id.to_owned().into(), - timestamp: OffsetDateTime::from_unix_timestamp_nanos(timestamp as i128)?, - duration, - exit, - command: command.to_owned(), - cwd: cwd.to_owned(), - session: session.to_owned(), - hostname: hostname.to_owned(), - deleted_at: deleted_at - .map(|t| OffsetDateTime::from_unix_timestamp_nanos(t as i128)) - .transpose()?, - }) - } - - pub fn deserialize(bytes: &[u8], version: &str) -> Result { - match version { - HISTORY_VERSION => Self::deserialize_v0(bytes), - - _ => bail!("unknown version {version:?}"), - } - } - - /// Builder for a history entry that is imported from shell history. - /// - /// The only two required fields are `timestamp` and `command`. - /// - /// ## Examples - /// ``` - /// use atuin_client::history::History; - /// - /// let history: History = History::import() - /// .timestamp(time::OffsetDateTime::now_utc()) - /// .command("ls -la") - /// .build() - /// .into(); - /// ``` - /// - /// If shell history contains more information, it can be added to the builder: - /// ``` - /// use atuin_client::history::History; - /// - /// let history: History = History::import() - /// .timestamp(time::OffsetDateTime::now_utc()) - /// .command("ls -la") - /// .cwd("/home/user") - /// .exit(0) - /// .duration(100) - /// .build() - /// .into(); - /// ``` - /// - /// Unknown command or command without timestamp cannot be imported, which - /// is forced at compile time: - /// - /// ```compile_fail - /// use atuin_client::history::History; - /// - /// // this will not compile because timestamp is missing - /// let history: History = History::import() - /// .command("ls -la") - /// .build() - /// .into(); - /// ``` - pub fn import() -> builder::HistoryImportedBuilder { - builder::HistoryImported::builder() - } - - /// 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 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 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 fn capture() -> builder::HistoryCapturedBuilder { - builder::HistoryCaptured::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 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()) - /// .deleted_at(None) - /// .build() - /// .into(); - /// ``` - pub fn from_db() -> builder::HistoryFromDbBuilder { - builder::HistoryFromDb::builder() - } - - pub fn success(&self) -> bool { - self.exit == 0 || self.duration == -1 - } - - pub fn should_save(&self, settings: &Settings) -> bool { - let secret_regex = SECRET_PATTERNS.iter().map(|f| f.1); - let secret_regex = RegexSet::new(secret_regex).expect("Failed to build secrets regex"); - - !(self.command.starts_with(' ') - || settings.history_filter.is_match(&self.command) - || settings.cwd_filter.is_match(&self.cwd) - || (secret_regex.is_match(&self.command)) && settings.secrets_filter) - } -} - -#[cfg(test)] -mod tests { - use regex::RegexSet; - use time::macros::datetime; - - use crate::{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::utc() - }; - - 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 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!(!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::utc() - }; - - 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 bytes = [ - 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 history = History { - id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(), - timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00), - duration: 49206000, - 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(), - deleted_at: None, - }; - - let serialized = history.serialize().expect("failed to serialize history"); - assert_eq!(serialized.0, bytes); - - let deserialized = History::deserialize(&serialized.0, HISTORY_VERSION) - .expect("failed to deserialize history"); - assert_eq!(history, deserialized); - - // test the snapshot too - let deserialized = - History::deserialize(&bytes, 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: 49206000, - 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(), - 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_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, - ]; - - // some other version - let bytes_v1 = [ - 205, 1, 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, HISTORY_VERSION); - assert!(deserialized.is_ok()); - - let deserialized = History::deserialize(&bytes_v1, HISTORY_VERSION); - assert!(deserialized.is_err()); - } -} diff --git a/atuin-client/src/history/builder.rs b/atuin-client/src/history/builder.rs deleted file mode 100644 index 4e69cf66..00000000 --- a/atuin-client/src/history/builder.rs +++ /dev/null @@ -1,99 +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 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, - #[builder(default, setter(strip_option, into))] - hostname: Option, -} - -impl From for History { - fn from(imported: HistoryImported) -> Self { - History::new( - imported.timestamp, - imported.command, - imported.cwd, - imported.exit, - imported.duration, - imported.session, - imported.hostname, - 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 struct HistoryCaptured { - timestamp: time::OffsetDateTime, - #[builder(setter(into))] - command: String, - #[builder(setter(into))] - cwd: String, -} - -impl From for History { - fn from(captured: HistoryCaptured) -> Self { - History::new( - captured.timestamp, - captured.command, - captured.cwd, - -1, - -1, - None, - None, - 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 struct HistoryFromDb { - id: String, - timestamp: time::OffsetDateTime, - command: String, - cwd: String, - exit: i64, - duration: i64, - session: String, - hostname: String, - deleted_at: Option, -} - -impl From for History { - fn from(from_db: HistoryFromDb) -> Self { - History { - 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, - deleted_at: from_db.deleted_at, - } - } -} diff --git a/atuin-client/src/history/store.rs b/atuin-client/src/history/store.rs deleted file mode 100644 index fe2b7b92..00000000 --- a/atuin-client/src/history/store.rs +++ /dev/null @@ -1,410 +0,0 @@ -use std::{collections::HashSet, fmt::Write, time::Duration}; - -use eyre::{bail, eyre, Result}; -use indicatif::{ProgressBar, ProgressState, ProgressStyle}; -use rmp::decode::Bytes; - -use crate::{ - database::{current_context, Database}, - record::{encryption::PASETO_V4, sqlite_store::SqliteStore, store::Store}, -}; -use atuin_common::record::{DecryptedData, Host, HostId, Record, RecordId, RecordIdx}; - -use super::{History, HistoryId, HISTORY_TAG, HISTORY_VERSION}; - -#[derive(Debug)] -pub struct HistoryStore { - pub store: SqliteStore, - pub host_id: HostId, - pub encryption_key: [u8; 32], -} - -#[derive(Debug, Eq, PartialEq, Clone)] -pub 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 fn serialize(&self) -> Result { - // 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 { - HistoryRecord::Create(history) => { - // 0 -> a history create - encode::write_u8(&mut output, 0)?; - - let bytes = history.serialize()?; - - encode::write_bin(&mut output, &bytes.0)?; - } - HistoryRecord::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 fn deserialize(bytes: &DecryptedData, version: &str) -> Result { - use rmp::decode; - - fn error_report(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 - let _ = decode::read_bin_len(&mut bytes).map_err(error_report)?; - - let record = History::deserialize(bytes.remaining_slice(), version)?; - - Ok(HistoryRecord::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(HistoryRecord::Delete(id.to_string().into())) - } - - n => { - bail!("unknown HistoryRecord type {n}") - } - } - } -} - -impl HistoryStore { - pub fn new(store: SqliteStore, host_id: HostId, encryption_key: [u8; 32]) -> Self { - HistoryStore { - 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::(&self.encryption_key)) - .await?; - - Ok((id, idx)) - } - - async fn push_batch(&self, records: impl Iterator) -> 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::(&self.encryption_key); - - ret.push(record); - } - - self.store.push_batch(ret.iter()).await?; - - Ok(()) - } - - pub async fn delete(&self, id: HistoryId) -> Result<(RecordId, RecordIdx)> { - let record = HistoryRecord::Delete(id); - - self.push_record(record).await - } - - pub 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 async fn history(&self) -> Result> { - // 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.into_iter() { - let hist = match record.version.as_str() { - HISTORY_VERSION => { - let decrypted = record.decrypt::(&self.encryption_key)?; - - HistoryRecord::deserialize(&decrypted.data, HISTORY_VERSION) - } - version => bail!("unknown history version {version:?}"), - }?; - - ret.push(hist); - } - - Ok(ret) - } - - pub async fn build(&self, database: &dyn Database) -> 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 async fn incremental_build(&self, database: &dyn Database, ids: &[RecordId]) -> Result<()> { - for id in ids { - let record = self.store.get(*id).await; - - let record = if let Ok(record) = record { - record - } else { - continue; - }; - - if record.tag != HISTORY_TAG { - continue; - } - - let decrypted = record.decrypt::(&self.encryption_key)?; - let record = HistoryRecord::deserialize(&decrypted.data, HISTORY_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 async fn history_ids(&self) -> Result> { - let history = self.history().await?; - - let ret = HashSet::from_iter(history.iter().map(|h| match h { - HistoryRecord::Create(h) => h.id.clone(), - HistoryRecord::Delete(id) => id.clone(), - })); - - Ok(ret) - } - - pub async fn init_store(&self, db: &impl Database) -> 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(); - 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 atuin_common::record::DecryptedData; - use time::macros::datetime; - - use crate::history::{store::HistoryRecord, HISTORY_VERSION}; - - use super::History; - - #[test] - fn test_serialize_deserialize_create() { - let bytes = [ - 204, 0, 196, 141, 205, 0, 0, 153, 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, - ]; - - 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(), - 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/atuin-client/src/import/bash.rs b/atuin-client/src/import/bash.rs deleted file mode 100644 index ade1f751..00000000 --- a/atuin-client/src/import/bash.rs +++ /dev/null @@ -1,218 +0,0 @@ -use std::{path::PathBuf, str}; - -use async_trait::async_trait; -use directories::UserDirs; -use eyre::{eyre, Result}; -use itertools::Itertools; -use time::{Duration, OffsetDateTime}; - -use super::{get_histpath, unix_byte_lines, Importer, Loader}; -use crate::history::History; -use crate::import::read_to_end; - -#[derive(Debug)] -pub struct Bash { - bytes: Vec, -} - -fn default_histpath() -> Result { - let user_dirs = UserDirs::new().ok_or_else(|| eyre!("could not find user directories"))?; - let home_dir = user_dirs.home_dir(); - - Ok(home_dir.join(".bash_history")) -} - -#[async_trait] -impl Importer for Bash { - const NAME: &'static str = "bash"; - - async fn new() -> Result { - let bytes = read_to_end(get_histpath(default_histpath)?)?; - Ok(Self { bytes }) - } - - async fn entries(&mut self) -> Result { - let count = unix_byte_lines(&self.bytes) - .map(LineType::from) - .filter(|line| matches!(line, LineType::Command(_))) - .count(); - Ok(count) - } - - async fn load(self, h: &mut impl Loader) -> Result<()> { - let lines = unix_byte_lines(&self.bytes) - .map(LineType::from) - .filter(|line| !matches!(line, LineType::NotUtf8)) // invalid utf8 are ignored - .collect_vec(); - - let (commands_before_first_timestamp, first_timestamp) = lines - .iter() - .enumerate() - .find_map(|(i, line)| match line { - LineType::Timestamp(t) => Some((i, *t)), - _ => None, - }) - // if no known timestamps, use now as base - .unwrap_or((lines.len(), OffsetDateTime::now_utc())); - - // if no timestamp is recorded, then use this increment to set an arbitrary timestamp - // to preserve ordering - // this increment is deliberately very small to prevent particularly fast fingers - // causing ordering issues; it also helps in handling the "here document" syntax, - // where several lines are recorded in succession without individual timestamps - let timestamp_increment = Duration::milliseconds(1); - - // make sure there is a minimum amount of time before the first known timestamp - // to fit all commands, given the default increment - let mut next_timestamp = - first_timestamp - timestamp_increment * commands_before_first_timestamp as i32; - - for line in lines.into_iter() { - match line { - LineType::NotUtf8 => unreachable!(), // already filtered - LineType::Empty => {} // do nothing - LineType::Timestamp(t) => { - if t < next_timestamp { - warn!("Time reversal detected in Bash history! Commands may be ordered incorrectly."); - } - next_timestamp = t; - } - LineType::Command(c) => { - let imported = History::import().timestamp(next_timestamp).command(c); - - h.push(imported.build().into()).await?; - next_timestamp += timestamp_increment; - } - } - } - - Ok(()) - } -} - -#[derive(Debug, Clone)] -enum LineType<'a> { - NotUtf8, - /// Can happen when using the "here document" syntax. - Empty, - /// A timestamp line start with a '#', followed immediately by an integer - /// that represents seconds since UNIX epoch. - Timestamp(OffsetDateTime), - /// Anything else. - Command(&'a str), -} -impl<'a> From<&'a [u8]> for LineType<'a> { - fn from(bytes: &'a [u8]) -> Self { - let Ok(line) = str::from_utf8(bytes) else { - return LineType::NotUtf8; - }; - if line.is_empty() { - return LineType::Empty; - } - let parsed = match try_parse_line_as_timestamp(line) { - Some(time) => LineType::Timestamp(time), - None => LineType::Command(line), - }; - parsed - } -} - -fn try_parse_line_as_timestamp(line: &str) -> Option { - let seconds = line.strip_prefix('#')?.parse().ok()?; - OffsetDateTime::from_unix_timestamp(seconds).ok() -} - -#[cfg(test)] -mod test { - use std::cmp::Ordering; - - use itertools::{assert_equal, Itertools}; - - use crate::import::{tests::TestLoader, Importer}; - - use super::Bash; - - #[tokio::test] - async fn parse_no_timestamps() { - let bytes = r"cargo install atuin -cargo update -cargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷ -" - .as_bytes() - .to_owned(); - - let mut bash = Bash { bytes }; - assert_eq!(bash.entries().await.unwrap(), 3); - - let mut loader = TestLoader::default(); - bash.load(&mut loader).await.unwrap(); - - assert_equal( - loader.buf.iter().map(|h| h.command.as_str()), - [ - "cargo install atuin", - "cargo update", - "cargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷", - ], - ); - assert!(is_strictly_sorted(loader.buf.iter().map(|h| h.timestamp))) - } - - #[tokio::test] - async fn parse_with_timestamps() { - let bytes = b"#1672918999 -git reset -#1672919006 -git clean -dxf -#1672919020 -cd ../ -" - .to_vec(); - - let mut bash = Bash { bytes }; - assert_eq!(bash.entries().await.unwrap(), 3); - - let mut loader = TestLoader::default(); - bash.load(&mut loader).await.unwrap(); - - assert_equal( - loader.buf.iter().map(|h| h.command.as_str()), - ["git reset", "git clean -dxf", "cd ../"], - ); - assert_equal( - loader.buf.iter().map(|h| h.timestamp.unix_timestamp()), - [1672918999, 1672919006, 1672919020], - ) - } - - #[tokio::test] - async fn parse_with_partial_timestamps() { - let bytes = b"git reset -#1672919006 -git clean -dxf -cd ../ -" - .to_vec(); - - let mut bash = Bash { bytes }; - assert_eq!(bash.entries().await.unwrap(), 3); - - let mut loader = TestLoader::default(); - bash.load(&mut loader).await.unwrap(); - - assert_equal( - loader.buf.iter().map(|h| h.command.as_str()), - ["git reset", "git clean -dxf", "cd ../"], - ); - assert!(is_strictly_sorted(loader.buf.iter().map(|h| h.timestamp))) - } - - fn is_strictly_sorted(iter: impl IntoIterator) -> bool - where - T: Clone + PartialOrd, - { - iter.into_iter() - .tuple_windows() - .all(|(a, b)| matches!(a.partial_cmp(&b), Some(Ordering::Less))) - } -} diff --git a/atuin-client/src/import/fish.rs b/atuin-client/src/import/fish.rs deleted file mode 100644 index 714b2d01..00000000 --- a/atuin-client/src/import/fish.rs +++ /dev/null @@ -1,179 +0,0 @@ -// import old shell history! -// automatically hoover up all that we can find - -use std::path::PathBuf; - -use async_trait::async_trait; -use directories::BaseDirs; -use eyre::{eyre, Result}; -use time::OffsetDateTime; - -use super::{unix_byte_lines, Importer, Loader}; -use crate::history::History; -use crate::import::read_to_end; - -#[derive(Debug)] -pub struct Fish { - bytes: Vec, -} - -/// see https://fishshell.com/docs/current/interactive.html#searchable-command-history -fn default_histpath() -> Result { - let base = BaseDirs::new().ok_or_else(|| eyre!("could not determine data directory"))?; - let data = std::env::var("XDG_DATA_HOME").map_or_else( - |_| base.home_dir().join(".local").join("share"), - PathBuf::from, - ); - - // fish supports multiple history sessions - // If `fish_history` var is missing, or set to `default`, use `fish` as the session - let session = std::env::var("fish_history").unwrap_or_else(|_| String::from("fish")); - let session = if session == "default" { - String::from("fish") - } else { - session - }; - - let mut histpath = data.join("fish"); - histpath.push(format!("{session}_history")); - - if histpath.exists() { - Ok(histpath) - } else { - Err(eyre!("Could not find history file.")) - } -} - -#[async_trait] -impl Importer for Fish { - const NAME: &'static str = "fish"; - - async fn new() -> Result { - let bytes = read_to_end(default_histpath()?)?; - Ok(Self { bytes }) - } - - async fn entries(&mut self) -> Result { - Ok(super::count_lines(&self.bytes)) - } - - async fn load(self, loader: &mut impl Loader) -> Result<()> { - let now = OffsetDateTime::now_utc(); - let mut time: Option = None; - let mut cmd: Option = None; - - for b in unix_byte_lines(&self.bytes) { - let s = match std::str::from_utf8(b) { - Ok(s) => s, - Err(_) => continue, // we can skip past things like invalid utf8 - }; - - if let Some(c) = s.strip_prefix("- cmd: ") { - // first, we must deal with the prev cmd - if let Some(cmd) = cmd.take() { - let time = time.unwrap_or(now); - let entry = History::import().timestamp(time).command(cmd); - - loader.push(entry.build().into()).await?; - } - - // using raw strings to avoid needing escaping. - // replaces double backslashes with single backslashes - let c = c.replace(r"\\", r"\"); - // replaces escaped newlines - let c = c.replace(r"\n", "\n"); - // TODO: any other escape characters? - - cmd = Some(c); - } else if let Some(t) = s.strip_prefix(" when: ") { - // if t is not an int, just ignore this line - if let Ok(t) = t.parse::() { - time = Some(OffsetDateTime::from_unix_timestamp(t)?); - } - } else { - // ... ignore paths lines - } - } - - // we might have a trailing cmd - if let Some(cmd) = cmd.take() { - let time = time.unwrap_or(now); - let entry = History::import().timestamp(time).command(cmd); - - loader.push(entry.build().into()).await?; - } - - Ok(()) - } -} - -#[cfg(test)] -mod test { - - use crate::import::{tests::TestLoader, Importer}; - - use super::Fish; - - #[tokio::test] - async fn parse_complex() { - // complicated input with varying contents and escaped strings. - let bytes = r#"- cmd: history --help - when: 1639162832 -- cmd: cat ~/.bash_history - when: 1639162851 - paths: - - ~/.bash_history -- cmd: ls ~/.local/share/fish/fish_history - when: 1639162890 - paths: - - ~/.local/share/fish/fish_history -- cmd: cat ~/.local/share/fish/fish_history - when: 1639162893 - paths: - - ~/.local/share/fish/fish_history -ERROR -- CORRUPTED: ENTRY - CONTINUE: - - AS - - NORMAL -- cmd: echo "foo" \\\n'bar' baz - when: 1639162933 -- cmd: cat ~/.local/share/fish/fish_history - when: 1639162939 - paths: - - ~/.local/share/fish/fish_history -- cmd: echo "\\"" \\\\ "\\\\" - when: 1639163063 -- cmd: cat ~/.local/share/fish/fish_history - when: 1639163066 - paths: - - ~/.local/share/fish/fish_history -"# - .as_bytes() - .to_owned(); - - let fish = Fish { bytes }; - - let mut loader = TestLoader::default(); - fish.load(&mut loader).await.unwrap(); - let mut history = loader.buf.into_iter(); - - // simple wrapper for fish history entry - macro_rules! fishtory { - ($timestamp:expr, $command:expr) => { - let h = history.next().expect("missing entry in history"); - assert_eq!(h.command.as_str(), $command); - assert_eq!(h.timestamp.unix_timestamp(), $timestamp); - }; - } - - fishtory!(1639162832, "history --help"); - fishtory!(1639162851, "cat ~/.bash_history"); - fishtory!(1639162890, "ls ~/.local/share/fish/fish_history"); - fishtory!(1639162893, "cat ~/.local/share/fish/fish_history"); - fishtory!(1639162933, "echo \"foo\" \\\n'bar' baz"); - fishtory!(1639162939, "cat ~/.local/share/fish/fish_history"); - fishtory!(1639163063, r#"echo "\"" \\ "\\""#); - fishtory!(1639163066, "cat ~/.local/share/fish/fish_history"); - } -} diff --git a/atuin-client/src/import/mod.rs b/atuin-client/src/import/mod.rs deleted file mode 100644 index c9d8c798..00000000 --- a/atuin-client/src/import/mod.rs +++ /dev/null @@ -1,111 +0,0 @@ -use std::fs::File; -use std::io::Read; -use std::path::PathBuf; - -use async_trait::async_trait; -use eyre::{bail, Result}; -use memchr::Memchr; - -use crate::history::History; - -pub mod bash; -pub mod fish; -pub mod nu; -pub mod nu_histdb; -pub mod resh; -pub mod xonsh; -pub mod xonsh_sqlite; -pub mod zsh; -pub mod zsh_histdb; - -#[async_trait] -pub trait Importer: Sized { - const NAME: &'static str; - async fn new() -> Result; - async fn entries(&mut self) -> Result; - async fn load(self, loader: &mut impl Loader) -> Result<()>; -} - -#[async_trait] -pub trait Loader: Sync + Send { - async fn push(&mut self, hist: History) -> eyre::Result<()>; -} - -fn unix_byte_lines(input: &[u8]) -> impl Iterator { - UnixByteLines { - iter: memchr::memchr_iter(b'\n', input), - bytes: input, - i: 0, - } -} - -struct UnixByteLines<'a> { - iter: Memchr<'a>, - bytes: &'a [u8], - i: usize, -} - -impl<'a> Iterator for UnixByteLines<'a> { - type Item = &'a [u8]; - - fn next(&mut self) -> Option { - let j = self.iter.next()?; - let out = &self.bytes[self.i..j]; - self.i = j + 1; - Some(out) - } - - fn count(self) -> usize - where - Self: Sized, - { - self.iter.count() - } -} - -fn count_lines(input: &[u8]) -> usize { - unix_byte_lines(input).count() -} - -fn get_histpath(def: D) -> Result -where - D: FnOnce() -> Result, -{ - if let Ok(p) = std::env::var("HISTFILE") { - is_file(PathBuf::from(p)) - } else { - is_file(def()?) - } -} - -fn read_to_end(path: PathBuf) -> Result> { - let mut bytes = Vec::new(); - let mut f = File::open(path)?; - f.read_to_end(&mut bytes)?; - Ok(bytes) -} -fn is_file(p: PathBuf) -> Result { - if p.is_file() { - Ok(p) - } else { - bail!("Could not find history file {:?}. Try setting $HISTFILE", p) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[derive(Default)] - pub struct TestLoader { - pub buf: Vec, - } - - #[async_trait] - impl Loader for TestLoader { - async fn push(&mut self, hist: History) -> Result<()> { - self.buf.push(hist); - Ok(()) - } - } -} diff --git a/atuin-client/src/import/nu.rs b/atuin-client/src/import/nu.rs deleted file mode 100644 index a45d83c5..00000000 --- a/atuin-client/src/import/nu.rs +++ /dev/null @@ -1,67 +0,0 @@ -// import old shell history! -// automatically hoover up all that we can find - -use std::path::PathBuf; - -use async_trait::async_trait; -use directories::BaseDirs; -use eyre::{eyre, Result}; -use time::OffsetDateTime; - -use super::{unix_byte_lines, Importer, Loader}; -use crate::history::History; -use crate::import::read_to_end; - -#[derive(Debug)] -pub struct Nu { - bytes: Vec, -} - -fn get_histpath() -> Result { - let base = BaseDirs::new().ok_or_else(|| eyre!("could not determine data directory"))?; - let config_dir = base.config_dir().join("nushell"); - - let histpath = config_dir.join("history.txt"); - if histpath.exists() { - Ok(histpath) - } else { - Err(eyre!("Could not find history file.")) - } -} - -#[async_trait] -impl Importer for Nu { - const NAME: &'static str = "nu"; - - async fn new() -> Result { - let bytes = read_to_end(get_histpath()?)?; - Ok(Self { bytes }) - } - - async fn entries(&mut self) -> Result { - Ok(super::count_lines(&self.bytes)) - } - - async fn load(self, h: &mut impl Loader) -> Result<()> { - let now = OffsetDateTime::now_utc(); - - let mut counter = 0; - for b in unix_byte_lines(&self.bytes) { - let s = match std::str::from_utf8(b) { - Ok(s) => s, - Err(_) => continue, // we can skip past things like invalid utf8 - }; - - let cmd: String = s.replace("<\\n>", "\n"); - - let offset = time::Duration::nanoseconds(counter); - counter += 1; - - let entry = History::import().timestamp(now - offset).command(cmd); - - h.push(entry.build().into()).await?; - } - - Ok(()) - } -} diff --git a/atuin-client/src/import/nu_histdb.rs b/atuin-client/src/import/nu_histdb.rs deleted file mode 100644 index f0e8e95c..00000000 --- a/atuin-client/src/import/nu_histdb.rs +++ /dev/null @@ -1,113 +0,0 @@ -// import old shell history! -// automatically hoover up all that we can find - -use std::path::PathBuf; - -use async_trait::async_trait; -use directories::BaseDirs; -use eyre::{eyre, Result}; -use sqlx::{sqlite::SqlitePool, Pool}; -use time::{Duration, OffsetDateTime}; - -use super::Importer; -use crate::history::History; -use crate::import::Loader; - -#[derive(sqlx::FromRow, Debug)] -pub struct HistDbEntry { - pub id: i64, - pub command_line: Vec, - pub start_timestamp: i64, - pub session_id: i64, - pub hostname: Vec, - pub cwd: Vec, - pub duration_ms: i64, - pub exit_status: i64, - pub more_info: Vec, -} - -impl From for History { - fn from(histdb_item: HistDbEntry) -> Self { - let ts_secs = histdb_item.start_timestamp / 1000; - let ts_ns = (histdb_item.start_timestamp % 1000) * 1_000_000; - let imported = History::import() - .timestamp( - OffsetDateTime::from_unix_timestamp(ts_secs).unwrap() - + Duration::nanoseconds(ts_ns), - ) - .command(String::from_utf8(histdb_item.command_line).unwrap()) - .cwd(String::from_utf8(histdb_item.cwd).unwrap()) - .exit(histdb_item.exit_status) - .duration(histdb_item.duration_ms) - .session(format!("{:x}", histdb_item.session_id)) - .hostname(String::from_utf8(histdb_item.hostname).unwrap()); - - imported.build().into() - } -} - -#[derive(Debug)] -pub struct NuHistDb { - histdb: Vec, -} - -/// Read db at given file, return vector of entries. -async fn hist_from_db(dbpath: PathBuf) -> Result> { - let pool = SqlitePool::connect(dbpath.to_str().unwrap()).await?; - hist_from_db_conn(pool).await -} - -async fn hist_from_db_conn(pool: Pool) -> Result> { - let query = r#" - SELECT - id, command_line, start_timestamp, session_id, hostname, cwd, duration_ms, exit_status, - more_info - FROM history - ORDER BY start_timestamp - "#; - let histdb_vec: Vec = sqlx::query_as::<_, HistDbEntry>(query) - .fetch_all(&pool) - .await?; - Ok(histdb_vec) -} - -impl NuHistDb { - pub fn histpath() -> Result { - let base = BaseDirs::new().ok_or_else(|| eyre!("could not determine data directory"))?; - let config_dir = base.config_dir().join("nushell"); - - let histdb_path = config_dir.join("history.sqlite3"); - if histdb_path.exists() { - Ok(histdb_path) - } else { - Err(eyre!("Could not find history file.")) - } - } -} - -#[async_trait] -impl Importer for NuHistDb { - // Not sure how this is used - const NAME: &'static str = "nu_histdb"; - - /// Creates a new NuHistDb and populates the history based on the pre-populated data - /// structure. - async fn new() -> Result { - let dbpath = NuHistDb::histpath()?; - let histdb_entry_vec = hist_from_db(dbpath).await?; - Ok(Self { - histdb: histdb_entry_vec, - }) - } - - async fn entries(&mut self) -> Result { - Ok(self.histdb.len()) - } - - async fn load(self, h: &mut impl Loader) -> Result<()> { - for i in self.histdb { - h.push(i.into()).await?; - } - Ok(()) - } -} diff --git a/atuin-client/src/import/resh.rs b/atuin-client/src/import/resh.rs deleted file mode 100644 index 396d11fd..00000000 --- a/atuin-client/src/import/resh.rs +++ /dev/null @@ -1,140 +0,0 @@ -use std::path::PathBuf; - -use async_trait::async_trait; -use directories::UserDirs; -use eyre::{eyre, Result}; -use serde::Deserialize; - -use atuin_common::utils::uuid_v7; -use time::OffsetDateTime; - -use super::{get_histpath, unix_byte_lines, Importer, Loader}; -use crate::history::History; -use crate::import::read_to_end; - -#[derive(Deserialize, Debug)] -#[serde(rename_all = "camelCase")] -pub struct ReshEntry { - pub cmd_line: String, - pub exit_code: i64, - pub shell: String, - pub uname: String, - pub session_id: String, - pub home: String, - pub lang: String, - pub lc_all: String, - pub login: String, - pub pwd: String, - pub pwd_after: String, - pub shell_env: String, - pub term: String, - pub real_pwd: String, - pub real_pwd_after: String, - pub pid: i64, - pub session_pid: i64, - pub host: String, - pub hosttype: String, - pub ostype: String, - pub machtype: String, - pub shlvl: i64, - pub timezone_before: String, - pub timezone_after: String, - pub realtime_before: f64, - pub realtime_after: f64, - pub realtime_before_local: f64, - pub realtime_after_local: f64, - pub realtime_duration: f64, - pub realtime_since_session_start: f64, - pub realtime_since_boot: f64, - pub git_dir: String, - pub git_real_dir: String, - pub git_origin_remote: String, - pub git_dir_after: String, - pub git_real_dir_after: String, - pub git_origin_remote_after: String, - pub machine_id: String, - pub os_release_id: String, - pub os_release_version_id: String, - pub os_release_id_like: String, - pub os_release_name: String, - pub os_release_pretty_name: String, - pub resh_uuid: String, - pub resh_version: String, - pub resh_revision: String, - pub parts_merged: bool, - pub recalled: bool, - pub recall_last_cmd_line: String, - pub cols: String, - pub lines: String, -} - -#[derive(Debug)] -pub struct Resh { - bytes: Vec, -} - -fn default_histpath() -> Result { - let user_dirs = UserDirs::new().ok_or_else(|| eyre!("could not find user directories"))?; - let home_dir = user_dirs.home_dir(); - - Ok(home_dir.join(".resh_history.json")) -} - -#[async_trait] -impl Importer for Resh { - const NAME: &'static str = "resh"; - - async fn new() -> Result { - let bytes = read_to_end(get_histpath(default_histpath)?)?; - Ok(Self { bytes }) - } - - async fn entries(&mut self) -> Result { - Ok(super::count_lines(&self.bytes)) - } - - async fn load(self, h: &mut impl Loader) -> Result<()> { - for b in unix_byte_lines(&self.bytes) { - let s = match std::str::from_utf8(b) { - Ok(s) => s, - Err(_) => continue, // we can skip past things like invalid utf8 - }; - let entry = match serde_json::from_str::(s) { - Ok(e) => e, - Err(_) => continue, // skip invalid json :shrug: - }; - - #[allow(clippy::cast_possible_truncation)] - #[allow(clippy::cast_sign_loss)] - let timestamp = { - let secs = entry.realtime_before.floor() as i64; - let nanosecs = (entry.realtime_before.fract() * 1_000_000_000_f64).round() as i64; - OffsetDateTime::from_unix_timestamp(secs)? + time::Duration::nanoseconds(nanosecs) - }; - #[allow(clippy::cast_possible_truncation)] - #[allow(clippy::cast_sign_loss)] - let duration = { - let secs = entry.realtime_after.floor() as i64; - let nanosecs = (entry.realtime_after.fract() * 1_000_000_000_f64).round() as i64; - let base = OffsetDateTime::from_unix_timestamp(secs)? - + time::Duration::nanoseconds(nanosecs); - let difference = base - timestamp; - difference.whole_nanoseconds() as i64 - }; - - let imported = History::import() - .command(entry.cmd_line) - .timestamp(timestamp) - .duration(duration) - .exit(entry.exit_code) - .cwd(entry.pwd) - .hostname(entry.host) - // CHECK: should we add uuid here? It's not set in the other importers - .session(uuid_v7().as_simple().to_string()); - - h.push(imported.build().into()).await?; - } - - Ok(()) - } -} diff --git a/atuin-client/src/import/xonsh.rs b/atuin-client/src/import/xonsh.rs deleted file mode 100644 index 19ce4cf6..00000000 --- a/atuin-client/src/import/xonsh.rs +++ /dev/null @@ -1,233 +0,0 @@ -use std::env; -use std::fs::{self, File}; -use std::path::{Path, PathBuf}; - -use async_trait::async_trait; -use directories::BaseDirs; -use eyre::{eyre, Result}; -use serde::Deserialize; -use time::OffsetDateTime; -use uuid::timestamp::{context::NoContext, Timestamp}; -use uuid::Uuid; - -use super::{get_histpath, Importer, Loader}; -use crate::history::History; -use crate::utils::get_host_user; - -// Note: both HistoryFile and HistoryData have other keys present in the JSON, we don't -// care about them so we leave them unspecified so as to avoid deserializing unnecessarily. -#[derive(Debug, Deserialize)] -struct HistoryFile { - data: HistoryData, -} - -#[derive(Debug, Deserialize)] -struct HistoryData { - sessionid: String, - cmds: Vec, -} - -#[derive(Debug, Deserialize)] -struct HistoryCmd { - cwd: String, - inp: String, - rtn: Option, - ts: (f64, f64), -} - -#[derive(Debug)] -pub struct Xonsh { - // history is stored as a bunch of json files, one per session - sessions: Vec, - hostname: String, -} - -fn xonsh_hist_dir(xonsh_data_dir: Option) -> Result { - // if running within xonsh, this will be available - if let Some(d) = xonsh_data_dir { - let mut path = PathBuf::from(d); - path.push("history_json"); - return Ok(path); - } - - // otherwise, fall back to default - let base = BaseDirs::new().ok_or_else(|| eyre!("Could not determine home directory"))?; - - let hist_dir = base.data_dir().join("xonsh/history_json"); - if hist_dir.exists() || cfg!(test) { - Ok(hist_dir) - } else { - Err(eyre!("Could not find xonsh history files")) - } -} - -fn load_sessions(hist_dir: &Path) -> Result> { - let mut sessions = vec![]; - for entry in fs::read_dir(hist_dir)? { - let p = entry?.path(); - let ext = p.extension().and_then(|e| e.to_str()); - if p.is_file() && ext == Some("json") { - if let Some(data) = load_session(&p)? { - sessions.push(data); - } - } - } - Ok(sessions) -} - -fn load_session(path: &Path) -> Result> { - let file = File::open(path)?; - // empty files are not valid json, so we can't deserialize them - if file.metadata()?.len() == 0 { - return Ok(None); - } - - let mut hist_file: HistoryFile = serde_json::from_reader(file)?; - - // if there are commands in this session, replace the existing UUIDv4 - // with a UUIDv7 generated from the timestamp of the first command - if let Some(cmd) = hist_file.data.cmds.first() { - let seconds = cmd.ts.0.trunc() as u64; - let nanos = (cmd.ts.0.fract() * 1_000_000_000_f64) as u32; - let ts = Timestamp::from_unix(NoContext, seconds, nanos); - hist_file.data.sessionid = Uuid::new_v7(ts).to_string(); - } - Ok(Some(hist_file.data)) -} - -#[async_trait] -impl Importer for Xonsh { - const NAME: &'static str = "xonsh"; - - async fn new() -> Result { - // wrap xonsh-specific path resolver in general one so that it respects $HISTPATH - let xonsh_data_dir = env::var("XONSH_DATA_DIR").ok(); - let hist_dir = get_histpath(|| xonsh_hist_dir(xonsh_data_dir))?; - let sessions = load_sessions(&hist_dir)?; - let hostname = get_host_user(); - Ok(Xonsh { sessions, hostname }) - } - - async fn entries(&mut self) -> Result { - let total = self.sessions.iter().map(|s| s.cmds.len()).sum(); - Ok(total) - } - - async fn load(self, loader: &mut impl Loader) -> Result<()> { - for session in self.sessions { - for cmd in session.cmds { - let (start, end) = cmd.ts; - let ts_nanos = (start * 1_000_000_000_f64) as i128; - let timestamp = OffsetDateTime::from_unix_timestamp_nanos(ts_nanos)?; - - let duration = (end - start) * 1_000_000_000_f64; - - match cmd.rtn { - Some(exit) => { - let entry = History::import() - .timestamp(timestamp) - .duration(duration.trunc() as i64) - .exit(exit) - .command(cmd.inp.trim()) - .cwd(cmd.cwd) - .session(session.sessionid.clone()) - .hostname(self.hostname.clone()); - loader.push(entry.build().into()).await?; - } - None => { - let entry = History::import() - .timestamp(timestamp) - .duration(duration.trunc() as i64) - .command(cmd.inp.trim()) - .cwd(cmd.cwd) - .session(session.sessionid.clone()) - .hostname(self.hostname.clone()); - loader.push(entry.build().into()).await?; - } - } - } - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use time::macros::datetime; - - use super::*; - - use crate::history::History; - use crate::import::tests::TestLoader; - - #[test] - fn test_hist_dir_xonsh() { - let hist_dir = xonsh_hist_dir(Some("/home/user/xonsh_data".to_string())).unwrap(); - assert_eq!( - hist_dir, - PathBuf::from("/home/user/xonsh_data/history_json") - ); - } - - #[tokio::test] - async fn test_import() { - let dir = PathBuf::from("tests/data/xonsh"); - let sessions = load_sessions(&dir).unwrap(); - let hostname = "box:user".to_string(); - let xonsh = Xonsh { sessions, hostname }; - - let mut loader = TestLoader::default(); - xonsh.load(&mut loader).await.unwrap(); - // order in buf will depend on filenames, so sort by timestamp for consistency - loader.buf.sort_by_key(|h| h.timestamp); - for (actual, expected) in loader.buf.iter().zip(expected_hist_entries().iter()) { - assert_eq!(actual.timestamp, expected.timestamp); - assert_eq!(actual.command, expected.command); - assert_eq!(actual.cwd, expected.cwd); - assert_eq!(actual.exit, expected.exit); - assert_eq!(actual.duration, expected.duration); - assert_eq!(actual.hostname, expected.hostname); - } - } - - fn expected_hist_entries() -> [History; 4] { - [ - History::import() - .timestamp(datetime!(2024-02-6 04:17:59.478272256 +00:00:00)) - .command("echo hello world!".to_string()) - .cwd("/home/user/Documents/code/atuin".to_string()) - .exit(0) - .duration(4651069) - .hostname("box:user".to_string()) - .build() - .into(), - History::import() - .timestamp(datetime!(2024-02-06 04:18:01.70632832 +00:00:00)) - .command("ls -l".to_string()) - .cwd("/home/user/Documents/code/atuin".to_string()) - .exit(0) - .duration(21288633) - .hostname("box:user".to_string()) - .build() - .into(), - History::import() - .timestamp(datetime!(2024-02-06 17:41:31.142515968 +00:00:00)) - .command("false".to_string()) - .cwd("/home/user/Documents/code/atuin/atuin-client".to_string()) - .exit(1) - .duration(10269403) - .hostname("box:user".to_string()) - .build() - .into(), - History::import() - .timestamp(datetime!(2024-02-06 17:41:32.271584 +00:00:00)) - .command("exit".to_string()) - .cwd("/home/user/Documents/code/atuin/atuin-client".to_string()) - .exit(0) - .duration(4259347) - .hostname("box:user".to_string()) - .build() - .into(), - ] - } -} diff --git a/atuin-client/src/import/xonsh_sqlite.rs b/atuin-client/src/import/xonsh_sqlite.rs deleted file mode 100644 index 2817dc63..00000000 --- a/atuin-client/src/import/xonsh_sqlite.rs +++ /dev/null @@ -1,217 +0,0 @@ -use std::env; -use std::path::PathBuf; - -use async_trait::async_trait; -use directories::BaseDirs; -use eyre::{eyre, Result}; -use futures::TryStreamExt; -use sqlx::{sqlite::SqlitePool, FromRow, Row}; -use time::OffsetDateTime; -use uuid::timestamp::{context::NoContext, Timestamp}; -use uuid::Uuid; - -use super::{get_histpath, Importer, Loader}; -use crate::history::History; -use crate::utils::get_host_user; - -#[derive(Debug, FromRow)] -struct HistDbEntry { - inp: String, - rtn: Option, - tsb: f64, - tse: f64, - cwd: String, - session_start: f64, -} - -impl HistDbEntry { - fn into_hist_with_hostname(self, hostname: String) -> History { - let ts_nanos = (self.tsb * 1_000_000_000_f64) as i128; - let timestamp = OffsetDateTime::from_unix_timestamp_nanos(ts_nanos).unwrap(); - - let session_ts_seconds = self.session_start.trunc() as u64; - let session_ts_nanos = (self.session_start.fract() * 1_000_000_000_f64) as u32; - let session_ts = Timestamp::from_unix(NoContext, session_ts_seconds, session_ts_nanos); - let session_id = Uuid::new_v7(session_ts).to_string(); - let duration = (self.tse - self.tsb) * 1_000_000_000_f64; - - if let Some(exit) = self.rtn { - let imported = History::import() - .timestamp(timestamp) - .duration(duration.trunc() as i64) - .exit(exit) - .command(self.inp) - .cwd(self.cwd) - .session(session_id) - .hostname(hostname); - imported.build().into() - } else { - let imported = History::import() - .timestamp(timestamp) - .duration(duration.trunc() as i64) - .command(self.inp) - .cwd(self.cwd) - .session(session_id) - .hostname(hostname); - imported.build().into() - } - } -} - -fn xonsh_db_path(xonsh_data_dir: Option) -> Result { - // if running within xonsh, this will be available - if let Some(d) = xonsh_data_dir { - let mut path = PathBuf::from(d); - path.push("xonsh-history.sqlite"); - return Ok(path); - } - - // otherwise, fall back to default - let base = BaseDirs::new().ok_or_else(|| eyre!("Could not determine home directory"))?; - - let hist_file = base.data_dir().join("xonsh/xonsh-history.sqlite"); - if hist_file.exists() || cfg!(test) { - Ok(hist_file) - } else { - Err(eyre!( - "Could not find xonsh history db at: {}", - hist_file.to_string_lossy() - )) - } -} - -#[derive(Debug)] -pub struct XonshSqlite { - pool: SqlitePool, - hostname: String, -} - -#[async_trait] -impl Importer for XonshSqlite { - const NAME: &'static str = "xonsh_sqlite"; - - async fn new() -> Result { - // wrap xonsh-specific path resolver in general one so that it respects $HISTPATH - let xonsh_data_dir = env::var("XONSH_DATA_DIR").ok(); - let db_path = get_histpath(|| xonsh_db_path(xonsh_data_dir))?; - let connection_str = db_path.to_str().ok_or_else(|| { - eyre!( - "Invalid path for SQLite database: {}", - db_path.to_string_lossy() - ) - })?; - - let pool = SqlitePool::connect(connection_str).await?; - let hostname = get_host_user(); - Ok(XonshSqlite { pool, hostname }) - } - - async fn entries(&mut self) -> Result { - let query = "SELECT COUNT(*) FROM xonsh_history"; - let row = sqlx::query(query).fetch_one(&self.pool).await?; - let count: u32 = row.get(0); - Ok(count as usize) - } - - async fn load(self, loader: &mut impl Loader) -> Result<()> { - let query = r#" - SELECT inp, rtn, tsb, tse, cwd, - MIN(tsb) OVER (PARTITION BY sessionid) AS session_start - FROM xonsh_history - ORDER BY rowid - "#; - - let mut entries = sqlx::query_as::<_, HistDbEntry>(query).fetch(&self.pool); - - let mut count = 0; - while let Some(entry) = entries.try_next().await? { - let hist = entry.into_hist_with_hostname(self.hostname.clone()); - loader.push(hist).await?; - count += 1; - } - - println!("Loaded: {count}"); - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use time::macros::datetime; - - use super::*; - - use crate::history::History; - use crate::import::tests::TestLoader; - - #[test] - fn test_db_path_xonsh() { - let db_path = xonsh_db_path(Some("/home/user/xonsh_data".to_string())).unwrap(); - assert_eq!( - db_path, - PathBuf::from("/home/user/xonsh_data/xonsh-history.sqlite") - ); - } - - #[tokio::test] - async fn test_import() { - let connection_str = "tests/data/xonsh-history.sqlite"; - let xonsh_sqlite = XonshSqlite { - pool: SqlitePool::connect(connection_str).await.unwrap(), - hostname: "box:user".to_string(), - }; - - let mut loader = TestLoader::default(); - xonsh_sqlite.load(&mut loader).await.unwrap(); - - for (actual, expected) in loader.buf.iter().zip(expected_hist_entries().iter()) { - assert_eq!(actual.timestamp, expected.timestamp); - assert_eq!(actual.command, expected.command); - assert_eq!(actual.cwd, expected.cwd); - assert_eq!(actual.exit, expected.exit); - assert_eq!(actual.duration, expected.duration); - assert_eq!(actual.hostname, expected.hostname); - } - } - - fn expected_hist_entries() -> [History; 4] { - [ - History::import() - .timestamp(datetime!(2024-02-6 17:56:21.130956288 +00:00:00)) - .command("echo hello world!".to_string()) - .cwd("/home/user/Documents/code/atuin".to_string()) - .exit(0) - .duration(2628564) - .hostname("box:user".to_string()) - .build() - .into(), - History::import() - .timestamp(datetime!(2024-02-06 17:56:28.190406144 +00:00:00)) - .command("ls -l".to_string()) - .cwd("/home/user/Documents/code/atuin".to_string()) - .exit(0) - .duration(9371519) - .hostname("box:user".to_string()) - .build() - .into(), - History::import() - .timestamp(datetime!(2024-02-06 17:56:46.989020928 +00:00:00)) - .command("false".to_string()) - .cwd("/home/user/Documents/code/atuin".to_string()) - .exit(1) - .duration(17337560) - .hostname("box:user".to_string()) - .build() - .into(), - History::import() - .timestamp(datetime!(2024-02-06 17:56:48.218384128 +00:00:00)) - .command("exit".to_string()) - .cwd("/home/user/Documents/code/atuin".to_string()) - .exit(0) - .duration(4599094) - .hostname("box:user".to_string()) - .build() - .into(), - ] - } -} diff --git a/atuin-client/src/import/zsh.rs b/atuin-client/src/import/zsh.rs deleted file mode 100644 index 5bc8fc16..00000000 --- a/atuin-client/src/import/zsh.rs +++ /dev/null @@ -1,229 +0,0 @@ -// import old shell history! -// automatically hoover up all that we can find - -use std::borrow::Cow; -use std::path::PathBuf; - -use async_trait::async_trait; -use directories::UserDirs; -use eyre::{eyre, Result}; -use time::OffsetDateTime; - -use super::{get_histpath, unix_byte_lines, Importer, Loader}; -use crate::history::History; -use crate::import::read_to_end; - -#[derive(Debug)] -pub struct Zsh { - bytes: Vec, -} - -fn default_histpath() -> Result { - // oh-my-zsh sets HISTFILE=~/.zhistory - // zsh has no default value for this var, but uses ~/.zhistory. - // we could maybe be smarter about this in the future :) - let user_dirs = UserDirs::new().ok_or_else(|| eyre!("could not find user directories"))?; - let home_dir = user_dirs.home_dir(); - - let mut candidates = [".zhistory", ".zsh_history"].iter(); - loop { - match candidates.next() { - Some(candidate) => { - let histpath = home_dir.join(candidate); - if histpath.exists() { - break Ok(histpath); - } - } - None => { - break Err(eyre!( - "Could not find history file. Try setting and exporting $HISTFILE" - )) - } - } - } -} - -#[async_trait] -impl Importer for Zsh { - const NAME: &'static str = "zsh"; - - async fn new() -> Result { - let bytes = read_to_end(get_histpath(default_histpath)?)?; - Ok(Self { bytes }) - } - - async fn entries(&mut self) -> Result { - Ok(super::count_lines(&self.bytes)) - } - - async fn load(self, h: &mut impl Loader) -> Result<()> { - let now = OffsetDateTime::now_utc(); - let mut line = String::new(); - - let mut counter = 0; - for b in unix_byte_lines(&self.bytes) { - let s = match unmetafy(b) { - Some(s) => s, - _ => continue, // we can skip past things like invalid utf8 - }; - - if let Some(s) = s.strip_suffix('\\') { - line.push_str(s); - line.push_str("\\\n"); - } else { - line.push_str(&s); - let command = std::mem::take(&mut line); - - if let Some(command) = command.strip_prefix(": ") { - counter += 1; - h.push(parse_extended(command, counter)).await?; - } else { - let offset = time::Duration::seconds(counter); - counter += 1; - - let imported = History::import() - // preserve ordering - .timestamp(now - offset) - .command(command.trim_end().to_string()); - - h.push(imported.build().into()).await?; - } - } - } - - Ok(()) - } -} - -fn parse_extended(line: &str, counter: i64) -> History { - let (time, duration) = line.split_once(':').unwrap(); - let (duration, command) = duration.split_once(';').unwrap(); - - let time = time - .parse::() - .ok() - .and_then(|t| OffsetDateTime::from_unix_timestamp(t).ok()) - .unwrap_or_else(OffsetDateTime::now_utc) - + time::Duration::milliseconds(counter); - - // use nanos, because why the hell not? we won't display them. - let duration = duration.parse::().map_or(-1, |t| t * 1_000_000_000); - - let imported = History::import() - .timestamp(time) - .command(command.trim_end().to_string()) - .duration(duration); - - imported.build().into() -} - -fn unmetafy(line: &[u8]) -> Option> { - if line.contains(&0x83) { - let mut s = Vec::with_capacity(line.len()); - let mut is_meta = false; - for ch in line { - if *ch == 0x83 { - is_meta = true; - } else if is_meta { - is_meta = false; - s.push(*ch ^ 32); - } else { - s.push(*ch) - } - } - String::from_utf8(s).ok().map(Cow::Owned) - } else { - std::str::from_utf8(line).ok().map(Cow::Borrowed) - } -} - -#[cfg(test)] -mod test { - use itertools::assert_equal; - - use crate::import::tests::TestLoader; - - use super::*; - - #[test] - fn test_parse_extended_simple() { - let parsed = parse_extended("1613322469:0;cargo install atuin", 0); - - assert_eq!(parsed.command, "cargo install atuin"); - assert_eq!(parsed.duration, 0); - assert_eq!( - parsed.timestamp, - OffsetDateTime::from_unix_timestamp(1_613_322_469).unwrap() - ); - - let parsed = parse_extended("1613322469:10;cargo install atuin;cargo update", 0); - - assert_eq!(parsed.command, "cargo install atuin;cargo update"); - assert_eq!(parsed.duration, 10_000_000_000); - assert_eq!( - parsed.timestamp, - OffsetDateTime::from_unix_timestamp(1_613_322_469).unwrap() - ); - - let parsed = parse_extended("1613322469:10;cargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷", 0); - - assert_eq!(parsed.command, "cargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷"); - assert_eq!(parsed.duration, 10_000_000_000); - assert_eq!( - parsed.timestamp, - OffsetDateTime::from_unix_timestamp(1_613_322_469).unwrap() - ); - - let parsed = parse_extended("1613322469:10;cargo install \\n atuin\n", 0); - - assert_eq!(parsed.command, "cargo install \\n atuin"); - assert_eq!(parsed.duration, 10_000_000_000); - assert_eq!( - parsed.timestamp, - OffsetDateTime::from_unix_timestamp(1_613_322_469).unwrap() - ); - } - - #[tokio::test] - async fn test_parse_file() { - let bytes = r": 1613322469:0;cargo install atuin -: 1613322469:10;cargo install atuin; \ -cargo update -: 1613322469:10;cargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷ -" - .as_bytes() - .to_owned(); - - let mut zsh = Zsh { bytes }; - assert_eq!(zsh.entries().await.unwrap(), 4); - - let mut loader = TestLoader::default(); - zsh.load(&mut loader).await.unwrap(); - - assert_equal( - loader.buf.iter().map(|h| h.command.as_str()), - [ - "cargo install atuin", - "cargo install atuin; \\\ncargo update", - "cargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷", - ], - ); - } - - #[tokio::test] - async fn test_parse_metafied() { - let bytes = - b"echo \xe4\xbd\x83\x80\xe5\xa5\xbd\nls ~/\xe9\x83\xbf\xb3\xe4\xb9\x83\xb0\n".to_vec(); - - let mut zsh = Zsh { bytes }; - assert_eq!(zsh.entries().await.unwrap(), 2); - - let mut loader = TestLoader::default(); - zsh.load(&mut loader).await.unwrap(); - - assert_equal( - loader.buf.iter().map(|h| h.command.as_str()), - ["echo 你好", "ls ~/音乐"], - ); - } -} diff --git a/atuin-client/src/import/zsh_histdb.rs b/atuin-client/src/import/zsh_histdb.rs deleted file mode 100644 index eb72baa3..00000000 --- a/atuin-client/src/import/zsh_histdb.rs +++ /dev/null @@ -1,247 +0,0 @@ -// import old shell history from zsh-histdb! -// automatically hoover up all that we can find - -// As far as i can tell there are no version numbers in the histdb sqlite DB, so we're going based -// on the schema from 2022-05-01 -// -// I have run into some histories that will not import b/c of non UTF-8 characters. -// - -// -// An Example sqlite query for hsitdb data: -// -//id|session|command_id|place_id|exit_status|start_time|duration|id|argv|id|host|dir -// -// -// select -// history.id, -// history.start_time, -// places.host, -// places.dir, -// commands.argv -// from history -// left join commands on history.command_id = commands.id -// left join places on history.place_id = places.id ; -// -// CREATE TABLE history (id integer primary key autoincrement, -// session int, -// command_id int references commands (id), -// place_id int references places (id), -// exit_status int, -// start_time int, -// duration int); -// - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use async_trait::async_trait; -use atuin_common::utils::uuid_v7; -use directories::UserDirs; -use eyre::{eyre, Result}; -use sqlx::{sqlite::SqlitePool, Pool}; -use time::PrimitiveDateTime; - -use super::Importer; -use crate::history::History; -use crate::import::Loader; -use crate::utils::{get_hostname, get_username}; - -#[derive(sqlx::FromRow, Debug)] -pub struct HistDbEntryCount { - pub count: usize, -} - -#[derive(sqlx::FromRow, Debug)] -pub struct HistDbEntry { - pub id: i64, - pub start_time: PrimitiveDateTime, - pub host: Vec, - pub dir: Vec, - pub argv: Vec, - pub duration: i64, - pub exit_status: i64, - pub session: i64, -} - -#[derive(Debug)] -pub struct ZshHistDb { - histdb: Vec, - username: String, -} - -/// Read db at given file, return vector of entries. -async fn hist_from_db(dbpath: PathBuf) -> Result> { - let pool = SqlitePool::connect(dbpath.to_str().unwrap()).await?; - hist_from_db_conn(pool).await -} - -async fn hist_from_db_conn(pool: Pool) -> Result> { - let query = r#" - SELECT - history.id, history.start_time, history.duration, places.host, places.dir, - commands.argv, history.exit_status, history.session - FROM history - LEFT JOIN commands ON history.command_id = commands.id - LEFT JOIN places ON history.place_id = places.id - ORDER BY history.start_time - "#; - let histdb_vec: Vec = sqlx::query_as::<_, HistDbEntry>(query) - .fetch_all(&pool) - .await?; - Ok(histdb_vec) -} - -impl ZshHistDb { - pub fn histpath_candidate() -> PathBuf { - // By default histdb database is `${HOME}/.histdb/zsh-history.db` - // This can be modified by ${HISTDB_FILE} - // - // if [[ -z ${HISTDB_FILE} ]]; then - // typeset -g HISTDB_FILE="${HOME}/.histdb/zsh-history.db" - let user_dirs = UserDirs::new().unwrap(); // should catch error here? - let home_dir = user_dirs.home_dir(); - std::env::var("HISTDB_FILE") - .as_ref() - .map(|x| Path::new(x).to_path_buf()) - .unwrap_or_else(|_err| home_dir.join(".histdb/zsh-history.db")) - } - pub fn histpath() -> Result { - let histdb_path = ZshHistDb::histpath_candidate(); - if histdb_path.exists() { - Ok(histdb_path) - } else { - Err(eyre!( - "Could not find history file. Try setting $HISTDB_FILE" - )) - } - } -} - -#[async_trait] -impl Importer for ZshHistDb { - // Not sure how this is used - const NAME: &'static str = "zsh_histdb"; - - /// Creates a new ZshHistDb and populates the history based on the pre-populated data - /// structure. - async fn new() -> Result { - let dbpath = ZshHistDb::histpath()?; - let histdb_entry_vec = hist_from_db(dbpath).await?; - Ok(Self { - histdb: histdb_entry_vec, - username: get_username(), - }) - } - - async fn entries(&mut self) -> Result { - Ok(self.histdb.len()) - } - - async fn load(self, h: &mut impl Loader) -> Result<()> { - let mut session_map = HashMap::new(); - for entry in self.histdb { - let command = match std::str::from_utf8(&entry.argv) { - Ok(s) => s.trim_end(), - Err(_) => continue, // we can skip past things like invalid utf8 - }; - let cwd = match std::str::from_utf8(&entry.dir) { - Ok(s) => s.trim_end(), - Err(_) => continue, // we can skip past things like invalid utf8 - }; - let hostname = format!( - "{}:{}", - String::from_utf8(entry.host).unwrap_or_else(|_e| get_hostname()), - self.username - ); - let session = session_map.entry(entry.session).or_insert_with(uuid_v7); - - let imported = History::import() - .timestamp(entry.start_time.assume_utc()) - .command(command) - .cwd(cwd) - .duration(entry.duration * 1_000_000_000) - .exit(entry.exit_status) - .session(session.as_simple().to_string()) - .hostname(hostname) - .build(); - h.push(imported.into()).await?; - } - Ok(()) - } -} - -#[cfg(test)] -mod test { - - use super::*; - use sqlx::sqlite::SqlitePoolOptions; - use std::env; - #[tokio::test(flavor = "multi_thread")] - async fn test_env_vars() { - let test_env_db = "nonstd-zsh-history.db"; - let key = "HISTDB_FILE"; - env::set_var(key, test_env_db); - - // test the env got set - assert_eq!(env::var(key).unwrap(), test_env_db.to_string()); - - // test histdb returns the proper db from previous step - let histdb_path = ZshHistDb::histpath_candidate(); - assert_eq!(histdb_path.to_str().unwrap(), test_env_db); - } - - #[tokio::test(flavor = "multi_thread")] - async fn test_import() { - let pool: SqlitePool = SqlitePoolOptions::new() - .min_connections(2) - .connect(":memory:") - .await - .unwrap(); - - // sql dump directly from a test database. - let db_sql = r#" - PRAGMA foreign_keys=OFF; - BEGIN TRANSACTION; - CREATE TABLE commands (id integer primary key autoincrement, argv text, unique(argv) on conflict ignore); - INSERT INTO commands VALUES(1,'pwd'); - INSERT INTO commands VALUES(2,'curl google.com'); - INSERT INTO commands VALUES(3,'bash'); - CREATE TABLE places (id integer primary key autoincrement, host text, dir text, unique(host, dir) on conflict ignore); - INSERT INTO places VALUES(1,'mbp16.local','/home/noyez'); - CREATE TABLE history (id integer primary key autoincrement, - session int, - command_id int references commands (id), - place_id int references places (id), - exit_status int, - start_time int, - duration int); - INSERT INTO history VALUES(1,0,1,1,0,1651497918,1); - INSERT INTO history VALUES(2,0,2,1,0,1651497923,1); - INSERT INTO history VALUES(3,0,3,1,NULL,1651497930,NULL); - DELETE FROM sqlite_sequence; - INSERT INTO sqlite_sequence VALUES('commands',3); - INSERT INTO sqlite_sequence VALUES('places',3); - INSERT INTO sqlite_sequence VALUES('history',3); - CREATE INDEX hist_time on history(start_time); - CREATE INDEX place_dir on places(dir); - CREATE INDEX place_host on places(host); - CREATE INDEX history_command_place on history(command_id, place_id); - COMMIT; "#; - - sqlx::query(db_sql).execute(&pool).await.unwrap(); - - // test histdb iterator - let histdb_vec = hist_from_db_conn(pool).await.unwrap(); - let histdb = ZshHistDb { - histdb: histdb_vec, - username: get_username(), - }; - - println!("h: {:#?}", histdb.histdb); - println!("counter: {:?}", histdb.histdb.len()); - for i in histdb.histdb { - println!("{i:?}"); - } - } -} diff --git a/atuin-client/src/kv.rs b/atuin-client/src/kv.rs deleted file mode 100644 index fb26cadc..00000000 --- a/atuin-client/src/kv.rs +++ /dev/null @@ -1,265 +0,0 @@ -use std::collections::BTreeMap; - -use atuin_common::record::{DecryptedData, Host, HostId}; -use eyre::{bail, ensure, eyre, Result}; -use serde::Deserialize; - -use crate::record::encryption::PASETO_V4; -use crate::record::store::Store; - -const KV_VERSION: &str = "v0"; -const KV_TAG: &str = "kv"; -const KV_VAL_MAX_LEN: usize = 100 * 1024; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct KvRecord { - pub namespace: String, - pub key: String, - pub value: String, -} - -impl KvRecord { - pub fn serialize(&self) -> Result { - use rmp::encode; - - let mut output = vec![]; - - // INFO: ensure this is updated when adding new fields - encode::write_array_len(&mut output, 3)?; - - encode::write_str(&mut output, &self.namespace)?; - encode::write_str(&mut output, &self.key)?; - encode::write_str(&mut output, &self.value)?; - - Ok(DecryptedData(output)) - } - - pub fn deserialize(data: &DecryptedData, version: &str) -> Result { - use rmp::decode; - - fn error_report(err: E) -> eyre::Report { - eyre!("{err:?}") - } - - match version { - KV_VERSION => { - let mut bytes = decode::Bytes::new(&data.0); - - let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?; - ensure!(nfields == 3, "too many entries in v0 kv record"); - - let bytes = bytes.remaining_slice(); - - let (namespace, bytes) = - decode::read_str_from_slice(bytes).map_err(error_report)?; - let (key, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?; - let (value, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?; - - if !bytes.is_empty() { - bail!("trailing bytes in encoded kvrecord. malformed") - } - - Ok(KvRecord { - namespace: namespace.to_owned(), - key: key.to_owned(), - value: value.to_owned(), - }) - } - _ => { - bail!("unknown version {version:?}") - } - } - } -} - -#[derive(Debug, Clone, Deserialize)] -pub struct KvStore; - -impl Default for KvStore { - fn default() -> Self { - Self::new() - } -} - -impl KvStore { - // will want to init the actual kv store when that is done - pub fn new() -> KvStore { - KvStore {} - } - - pub async fn set( - &self, - store: &(impl Store + Send + Sync), - encryption_key: &[u8; 32], - host_id: HostId, - namespace: &str, - key: &str, - value: &str, - ) -> Result<()> { - if value.len() > KV_VAL_MAX_LEN { - return Err(eyre!( - "kv value too large: max len {} bytes", - KV_VAL_MAX_LEN - )); - } - - let record = KvRecord { - namespace: namespace.to_string(), - key: key.to_string(), - value: value.to_string(), - }; - - let bytes = record.serialize()?; - - let idx = store - .last(host_id, KV_TAG) - .await? - .map_or(0, |entry| entry.idx + 1); - - let record = atuin_common::record::Record::builder() - .host(Host::new(host_id)) - .version(KV_VERSION.to_string()) - .tag(KV_TAG.to_string()) - .idx(idx) - .data(bytes) - .build(); - - store - .push(&record.encrypt::(encryption_key)) - .await?; - - Ok(()) - } - - // TODO: setup an actual kv store, rebuild func, and do not pass the main store in here as - // well. - pub async fn get( - &self, - store: &impl Store, - encryption_key: &[u8; 32], - namespace: &str, - key: &str, - ) -> Result> { - // TODO: don't rebuild every time... - let map = self.build_kv(store, encryption_key).await?; - - let res = map.get(namespace); - - if let Some(ns) = res { - let value = ns.get(key); - - Ok(value.cloned()) - } else { - Ok(None) - } - } - - // Build a kv map out of the linked list kv store - // Map is Namespace -> Key -> Value - // TODO(ellie): "cache" this into a real kv structure, which we can - // use as a write-through cache to avoid constant rebuilds. - pub async fn build_kv( - &self, - store: &impl Store, - encryption_key: &[u8; 32], - ) -> Result>> { - let mut map = BTreeMap::new(); - - // TODO: maybe don't load the entire tag into memory to build the kv - // we can be smart about it and only load values since the last build - // or, iterate/paginate - let tagged = store.all_tagged(KV_TAG).await?; - - // iterate through all tags and play each KV record at a time - // this is "last write wins" - // probably good enough for now, but revisit in future - for record in tagged { - let decrypted = match record.version.as_str() { - KV_VERSION => record.decrypt::(encryption_key)?, - version => bail!("unknown version {version:?}"), - }; - - let kv = KvRecord::deserialize(&decrypted.data, KV_VERSION)?; - - let ns = map - .entry(kv.namespace.clone()) - .or_insert_with(BTreeMap::new); - - ns.insert(kv.key.clone(), kv); - } - - Ok(map) - } -} - -#[cfg(test)] -mod tests { - use crypto_secretbox::{KeyInit, XSalsa20Poly1305}; - use rand::rngs::OsRng; - - use crate::record::sqlite_store::{test_sqlite_store_timeout, SqliteStore}; - - use super::{KvRecord, KvStore, KV_VERSION}; - - #[test] - fn encode_decode() { - let kv = KvRecord { - namespace: "foo".to_owned(), - key: "bar".to_owned(), - value: "baz".to_owned(), - }; - let snapshot = [ - 0x93, 0xa3, b'f', b'o', b'o', 0xa3, b'b', b'a', b'r', 0xa3, b'b', b'a', b'z', - ]; - - let encoded = kv.serialize().unwrap(); - let decoded = KvRecord::deserialize(&encoded, KV_VERSION).unwrap(); - - assert_eq!(encoded.0, &snapshot); - assert_eq!(decoded, kv); - } - - #[tokio::test] - async fn build_kv() { - let mut store = SqliteStore::new(":memory:", test_sqlite_store_timeout()) - .await - .unwrap(); - let kv = KvStore::new(); - let key: [u8; 32] = XSalsa20Poly1305::generate_key(&mut OsRng).into(); - let host_id = atuin_common::record::HostId(atuin_common::utils::uuid_v7()); - - kv.set(&mut store, &key, host_id, "test-kv", "foo", "bar") - .await - .unwrap(); - - kv.set(&mut store, &key, host_id, "test-kv", "1", "2") - .await - .unwrap(); - - let map = kv.build_kv(&store, &key).await.unwrap(); - - assert_eq!( - *map.get("test-kv") - .expect("map namespace not set") - .get("foo") - .expect("map key not set"), - KvRecord { - namespace: String::from("test-kv"), - key: String::from("foo"), - value: String::from("bar") - } - ); - - assert_eq!( - *map.get("test-kv") - .expect("map namespace not set") - .get("1") - .expect("map key not set"), - KvRecord { - namespace: String::from("test-kv"), - key: String::from("1"), - value: String::from("2") - } - ); - } -} diff --git a/atuin-client/src/lib.rs b/atuin-client/src/lib.rs deleted file mode 100644 index 66258af3..00000000 --- a/atuin-client/src/lib.rs +++ /dev/null @@ -1,21 +0,0 @@ -#![forbid(unsafe_code)] - -#[macro_use] -extern crate log; - -#[cfg(feature = "sync")] -pub mod api_client; -#[cfg(feature = "sync")] -pub mod sync; - -pub mod database; -pub mod encryption; -pub mod history; -pub mod import; -pub mod kv; -pub mod ordering; -pub mod record; -pub mod secrets; -pub mod settings; - -mod utils; diff --git a/atuin-client/src/ordering.rs b/atuin-client/src/ordering.rs deleted file mode 100644 index 4e5ec84c..00000000 --- a/atuin-client/src/ordering.rs +++ /dev/null @@ -1,32 +0,0 @@ -use minspan::minspan; - -use super::{history::History, settings::SearchMode}; - -pub fn reorder_fuzzy(mode: SearchMode, query: &str, res: Vec) -> Vec { - match mode { - SearchMode::Fuzzy => reorder(query, |x| &x.command, res), - _ => res, - } -} - -fn reorder(query: &str, f: F, res: Vec) -> Vec -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 - let (from, to) = match minspan::span(qvec, &(f(h).chars().collect())) { - Some(x) => x, - // this 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. - None => (0, res.len()), - }; - 1 + to - from - }); - r -} diff --git a/atuin-client/src/record/encryption.rs b/atuin-client/src/record/encryption.rs deleted file mode 100644 index 3ad3be66..00000000 --- a/atuin-client/src/record/encryption.rs +++ /dev/null @@ -1,373 +0,0 @@ -use atuin_common::record::{ - AdditionalData, DecryptedData, EncryptedData, Encryption, HostId, RecordId, RecordIdx, -}; -use base64::{engine::general_purpose, Engine}; -use eyre::{ensure, Context, Result}; -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. -#[allow(non_camel_case_types)] -pub 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 - - - - - - - - - - - -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 { - 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::::new_os_random(); - - // encode the implicit assertions - 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::::from(&nonce); - - let token = Paseto::::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 { - 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::::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: String, key: &[u8; 32]) -> Result> { - let wrapping_key = Key::::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, key: &[u8; 32]) -> String { - // aka key-encryption-key (KEK) - let wrapping_key = Key::::from_bytes(*key); - - // wrap the random key so we can decrypt it later - let wrapped_cek = AtuinFooter { - wpk: cek.wrap_pie(&wrapping_key), - kid: wrapping_key.to_id(), - }; - 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. -/// -struct AtuinFooter { - /// Wrapped key - wpk: PieWrappedKey, - /// ID of the key which was used to wrap - kid: KeyId, -} - -/// 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> 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 atuin_common::{ - record::{Host, Record}, - utils::uuid_v7, - }; - - use super::*; - - #[test] - fn round_trip() { - let key = Key::::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::::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::::new_os_random(); - let fake_key = Key::::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 _ = PASETO_V4::decrypt(encrypted, ad, &fake_key.to_bytes()).unwrap_err(); - } - - #[test] - fn cannot_decrypt_different_id() { - let key = Key::::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 - }; - let _ = PASETO_V4::decrypt(encrypted, ad, &key.to_bytes()).unwrap_err(); - } - - #[test] - fn re_encrypt_round_trip() { - let key1 = Key::::new_os_random(); - let key2 = Key::::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(1687244806000000) - .data(DecryptedData(vec![1, 2, 3, 4])) - .idx(0) - .build(); - - let encrypted = record.encrypt::(&key); - - assert!(!encrypted.data.data.is_empty()); - assert!(!encrypted.data.content_encryption_key.is_empty()); - - let decrypted = encrypted.decrypt::(&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(1687244806000000) - .data(DecryptedData(vec![1, 2, 3, 4])) - .idx(0) - .build(); - - let encrypted = record.encrypt::(&key); - - let mut enc1 = encrypted.clone(); - enc1.host = Host::new(HostId(uuid_v7())); - let _ = enc1 - .decrypt::(&key) - .expect_err("tampering with the host should result in auth failure"); - - let mut enc2 = encrypted; - enc2.id = RecordId(uuid_v7()); - let _ = enc2 - .decrypt::(&key) - .expect_err("tampering with the id should result in auth failure"); - } -} diff --git a/atuin-client/src/record/mod.rs b/atuin-client/src/record/mod.rs deleted file mode 100644 index c40fd395..00000000 --- a/atuin-client/src/record/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod encryption; -pub mod sqlite_store; -pub mod store; - -#[cfg(feature = "sync")] -pub mod sync; diff --git a/atuin-client/src/record/sqlite_store.rs b/atuin-client/src/record/sqlite_store.rs deleted file mode 100644 index 31de311b..00000000 --- a/atuin-client/src/record/sqlite_store.rs +++ /dev/null @@ -1,641 +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::str::FromStr; -use std::{path::Path, time::Duration}; - -use async_trait::async_trait; -use eyre::{eyre, Result}; -use fs_err as fs; - -use sqlx::{ - sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteRow}, - Row, -}; - -use atuin_common::record::{ - EncryptedData, Host, HostId, Record, RecordId, RecordIdx, RecordStatus, -}; -use uuid::Uuid; - -use super::encryption::PASETO_V4; -use super::store::Store; - -#[derive(Debug, Clone)] -pub struct SqliteStore { - pool: SqlitePool, -} - -impl SqliteStore { - pub async fn new(path: impl AsRef, timeout: f64) -> Result { - let path = path.as_ref(); - - debug!("opening sqlite database at {:?}", path); - - let create = !path.exists(); - if create { - if let Some(dir) = path.parent() { - fs::create_dir_all(dir)?; - } - } - - let opts = SqliteConnectOptions::from_str(path.as_os_str().to_str().unwrap())? - .journal_mode(SqliteJournalMode::Wal) - .foreign_keys(true) - .create_if_missing(true); - - let pool = SqlitePoolOptions::new() - .acquire_timeout(Duration::from_secs_f64(timeout)) - .connect_with(opts) - .await?; - - Self::setup_db(&pool).await?; - - Ok(Self { pool }) - } - - async fn setup_db(pool: &SqlitePool) -> Result<()> { - debug!("running sqlite database setup"); - - sqlx::migrate!("./record-migrations").run(pool).await?; - - Ok(()) - } - - async fn save_raw( - tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, - r: &Record, - ) -> 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(()) - } - - fn query_row(row: SqliteRow) -> Record { - 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>> { - let res = sqlx::query("select * from store ") - .map(Self::query_row) - .fetch_all(&self.pool) - .await?; - - Ok(res) - } -} - -#[async_trait] -impl Store for SqliteStore { - async fn push_batch( - &self, - records: impl Iterator> + 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(()) - } - - async fn get(&self, id: RecordId) -> Result> { - 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) - } - - 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(()) - } - - async fn delete_all(&self) -> Result<()> { - sqlx::query("delete from store").execute(&self.pool).await?; - - Ok(()) - } - - async fn last(&self, host: HostId, tag: &str) -> Result>> { - 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)), - } - } - - async fn first(&self, host: HostId, tag: &str) -> Result>> { - self.idx(host, tag, 0).await - } - - async fn len_all(&self) -> Result { - let res: Result<(i64,), sqlx::Error> = sqlx::query_as("select count(*) from store") - .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), - } - } - - async fn len_tag(&self, tag: &str) -> Result { - 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), - } - } - - async fn len(&self, host: HostId, tag: &str) -> Result { - let last = self.last(host, tag).await?; - - if let Some(last) = last { - return Ok(last.idx + 1); - } - - return Ok(0); - } - - async fn next( - &self, - host: HostId, - tag: &str, - idx: RecordIdx, - limit: u64, - ) -> Result>> { - let res = - sqlx::query("select * from store where idx >= ?1 and host = ?2 and tag = ?3 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) - } - - async fn idx( - &self, - host: HostId, - tag: &str, - idx: RecordIdx, - ) -> Result>> { - 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)), - } - } - - async fn status(&self) -> Result { - let mut status = RecordStatus::new(); - - let res: Result, 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) - } - - async fn all_tagged(&self, tag: &str) -> Result>> { - 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. - 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::(old_key, new_key)) - .collect::>>()?; - - // 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. - async fn verify(&self, key: &[u8; 32]) -> Result<()> { - let all = self.load_all().await?; - - all.into_iter() - .map(|record| record.decrypt::(key)) - .collect::>>()?; - - 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. - async fn purge(&self, key: &[u8; 32]) -> Result<()> { - let all = self.load_all().await?; - - for record in all.iter() { - match record.clone().decrypt::(key) { - Ok(_) => continue, - Err(_) => { - println!( - "Failed to decrypt {}, deleting", - record.id.0.as_hyphenated() - ); - - self.delete(record.id).await?; - } - } - } - - Ok(()) - } -} - -#[cfg(test)] -pub(crate) fn test_sqlite_store_timeout() -> f64 { - std::env::var("ATUIN_TEST_SQLITE_STORE_TIMEOUT") - .ok() - .and_then(|x| x.parse().ok()) - .unwrap_or(0.1) -} - -#[cfg(test)] -mod tests { - use atuin_common::{ - record::{DecryptedData, EncryptedData, Host, HostId, Record}, - utils::uuid_v7, - }; - - use crate::{ - encryption::generate_encoded_key, - record::{encryption::PASETO_V4, store::Store}, - }; - - use super::{test_sqlite_store_timeout, SqliteStore}; - - fn test_record() -> Record { - Record::builder() - .host(Host::new(HostId(atuin_common::utils::uuid_v7()))) - .version("v1".into()) - .tag(atuin_common::utils::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_sqlite_store_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_sqlite_store_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_sqlite_store_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_sqlite_store_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_sqlite_store_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_sqlite_store_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_sqlite_store_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 len_different_tags() { - let db = SqliteStore::new(":memory:", test_sqlite_store_timeout()) - .await - .unwrap(); - - // these have different tags, so the len should be the same - // we model multiple stores within one database - // new store = new tag = independent length - let first = test_record(); - let second = test_record(); - - db.push(&first).await.unwrap(); - db.push(&second).await.unwrap(); - - let first_len = db.len(first.host.id, first.tag.as_str()).await.unwrap(); - let second_len = db.len(second.host.id, second.tag.as_str()).await.unwrap(); - - assert_eq!(first_len, 1, "expected length of 1 after insert"); - assert_eq!(second_len, 1, "expected length of 1 after insert"); - } - - #[tokio::test] - async fn append_a_bunch() { - let db = SqliteStore::new(":memory:", test_sqlite_store_timeout()) - .await - .unwrap(); - - let mut tail = test_record(); - db.push(&tail).await.expect("failed to push record"); - - for _ in 1..100 { - tail = tail.append(vec![1, 2, 3, 4]).encrypt::(&[0; 32]); - db.push(&tail).await.unwrap(); - } - - assert_eq!( - db.len(tail.host.id, tail.tag.as_str()).await.unwrap(), - 100, - "failed to insert 100 records" - ); - - assert_eq!( - db.len_tag(tail.tag.as_str()).await.unwrap(), - 100, - "failed to insert 100 records" - ); - } - - #[tokio::test] - async fn append_a_big_bunch() { - let db = SqliteStore::new(":memory:", test_sqlite_store_timeout()) - .await - .unwrap(); - - let mut records: Vec> = Vec::with_capacity(10000); - - let mut tail = test_record(); - records.push(tail.clone()); - - for _ in 1..10000 { - tail = tail.append(vec![1, 2, 3]).encrypt::(&[0; 32]); - records.push(tail.clone()); - } - - db.push_batch(records.iter()).await.unwrap(); - - assert_eq!( - db.len(tail.host.id, tail.tag.as_str()).await.unwrap(), - 10000, - "failed to insert 10k records" - ); - } - - #[tokio::test] - async fn re_encrypt() { - let store = SqliteStore::new(":memory:", test_sqlite_store_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::(&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::(&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::(&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::(&new_key.into()).unwrap(); - assert_eq!(decrypted.data.0, data); - } - - assert_eq!(store.len(host_id, "test").await.unwrap(), 10); - } -} diff --git a/atuin-client/src/record/store.rs b/atuin-client/src/record/store.rs deleted file mode 100644 index 49ca4968..00000000 --- a/atuin-client/src/record/store.rs +++ /dev/null @@ -1,60 +0,0 @@ -use async_trait::async_trait; -use eyre::Result; - -use atuin_common::record::{EncryptedData, HostId, Record, RecordId, RecordIdx, RecordStatus}; - -/// 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. -#[async_trait] -pub trait Store { - // Push a record - async fn push(&self, record: &Record) -> Result<()> { - self.push_batch(std::iter::once(record)).await - } - - // Push a batch of records, all in one transaction - async fn push_batch( - &self, - records: impl Iterator> + Send + Sync, - ) -> Result<()>; - - async fn get(&self, id: RecordId) -> Result>; - - async fn delete(&self, id: RecordId) -> Result<()>; - async fn delete_all(&self) -> Result<()>; - - async fn len_all(&self) -> Result; - async fn len(&self, host: HostId, tag: &str) -> Result; - async fn len_tag(&self, tag: &str) -> Result; - - async fn last(&self, host: HostId, tag: &str) -> Result>>; - async fn first(&self, host: HostId, tag: &str) -> Result>>; - - async fn re_encrypt(&self, old_key: &[u8; 32], new_key: &[u8; 32]) -> Result<()>; - async fn verify(&self, key: &[u8; 32]) -> Result<()>; - async fn purge(&self, key: &[u8; 32]) -> Result<()>; - - /// Get the next `limit` records, after and including the given index - async fn next( - &self, - host: HostId, - tag: &str, - idx: RecordIdx, - limit: u64, - ) -> Result>>; - - /// Get the first record for a given host and tag - async fn idx( - &self, - host: HostId, - tag: &str, - idx: RecordIdx, - ) -> Result>>; - - async fn status(&self) -> Result; - - /// Get all records for a given tag - async fn all_tagged(&self, tag: &str) -> Result>>; -} diff --git a/atuin-client/src/record/sync.rs b/atuin-client/src/record/sync.rs deleted file mode 100644 index 234c6442..00000000 --- a/atuin-client/src/record/sync.rs +++ /dev/null @@ -1,607 +0,0 @@ -// do a sync :O -use std::{cmp::Ordering, fmt::Write}; - -use eyre::Result; -use thiserror::Error; - -use super::store::Store; -use crate::{api_client::Client, settings::Settings}; - -use atuin_common::record::{Diff, HostId, RecordId, RecordIdx, RecordStatus}; -use indicatif::{ProgressBar, ProgressState, ProgressStyle}; - -#[derive(Error, Debug)] -pub enum SyncError { - #[error("the local store is ahead of the remote, but for another host. has remote lost data?")] - LocalAheadOtherHost, - - #[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 }, -} - -#[derive(Debug, Eq, PartialEq)] -pub enum Operation { - // Either upload or download until the states matches the below - Upload { - local: RecordIdx, - remote: Option, - host: HostId, - tag: String, - }, - Download { - local: Option, - remote: RecordIdx, - host: HostId, - tag: String, - }, - Noop { - host: HostId, - tag: String, - }, -} - -pub async fn diff( - settings: &Settings, - store: &impl Store, -) -> Result<(Vec, RecordStatus), SyncError> { - let client = Client::new( - &settings.sync_address, - &settings.session_token, - settings.network_connect_timeout, - settings.network_timeout, - ) - .map_err(|e| SyncError::OperationalError { msg: e.to_string() })?; - - 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 async fn operations( - diffs: Vec, - _store: &impl Store, -) -> Result, 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: &impl Store, - client: &Client<'_>, - host: HostId, - tag: String, - local: RecordIdx, - remote: Option, -) -> Result { - let remote = remote.unwrap_or(0); - let expected = local - remote; - let upload_page_size = 100; - 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 - ); - - // preload with the first entry if remote does not know of this store - loop { - let page = store - .next(host, tag.as_str(), remote + progress, upload_page_size) - .await - .map_err(|e| { - error!("failed to read upload page: {e:?}"); - - SyncError::LocalStoreError { msg: e.to_string() } - })?; - - client.post_records(&page).await.map_err(|e| { - error!("failed to post records: {e:?}"); - - SyncError::RemoteRequestError { msg: e.to_string() } - })?; - - pb.set_position(progress); - progress += page.len() as u64; - - if progress >= expected { - break; - } - } - - pb.finish_with_message("Uploaded records"); - - Ok(progress as i64) -} - -async fn sync_download( - store: &impl Store, - client: &Client<'_>, - host: HostId, - tag: String, - local: Option, - remote: RecordIdx, -) -> Result, SyncError> { - let local = local.unwrap_or(0); - let expected = remote - local; - let download_page_size = 100; - 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("#>-")); - - // preload with the first entry if remote does not know of this store - loop { - let page = client - .next_records(host, tag.clone(), local + progress, download_page_size) - .await - .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?; - - store - .push_batch(page.iter()) - .await - .map_err(|e| SyncError::LocalStoreError { msg: e.to_string() })?; - - ret.extend(page.iter().map(|f| f.id)); - - pb.set_position(progress); - progress += page.len() as u64; - - if progress >= expected { - break; - } - } - - pb.finish_with_message("Downloaded records"); - - Ok(ret) -} - -pub async fn sync_remote( - operations: Vec, - local_store: &impl Store, - settings: &Settings, -) -> Result<(i64, Vec), SyncError> { - let client = Client::new( - &settings.sync_address, - &settings.session_token, - settings.network_connect_timeout, - settings.network_timeout, - ) - .expect("failed to create client"); - - 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).await?, - - Operation::Download { - host, - tag, - local, - remote, - } => { - let mut d = sync_download(local_store, &client, host, tag, local, remote).await?; - downloaded.append(&mut d) - } - - Operation::Noop { .. } => continue, - } - } - - Ok((uploaded, downloaded)) -} - -pub async fn sync( - settings: &Settings, - store: &impl Store, -) -> Result<(i64, Vec), SyncError> { - let (diff, _) = diff(settings, store).await?; - let operations = operations(diff, store).await?; - let (uploaded, downloaded) = sync_remote(operations, store, settings).await?; - - Ok((uploaded, downloaded)) -} - -#[cfg(test)] -mod tests { - use atuin_common::record::{Diff, EncryptedData, HostId, Record}; - use pretty_assertions::assert_eq; - - use crate::record::{ - encryption::PASETO_V4, - sqlite_store::{test_sqlite_store_timeout, SqliteStore}, - store::Store, - sync::{self, Operation}, - }; - - fn test_record() -> Record { - Record::builder() - .host(atuin_common::record::Host::new(HostId( - atuin_common::utils::uuid_v7(), - ))) - .version("v1".into()) - .tag(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>, - remote_records: Vec>, - ) -> (SqliteStore, Vec) { - let local_store = SqliteStore::new(":memory:", test_sqlite_store_timeout()) - .await - .expect("failed to open in memory sqlite"); - let remote_store = SqliteStore::new(":memory:", test_sqlite_store_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).await.unwrap(); - - assert_eq!(operations.len(), 1); - - assert_eq!( - operations[0], - Operation::Upload { - host: record.host.id, - tag: record.tag, - local: record.idx, - remote: None, - } - ); - } - - #[tokio::test] - async fn build_two_way_diff() { - // a diff where local is ahead of remote for one, and remote for - // another. One upload, one download - - let shared_record = test_record(); - let remote_ahead = test_record(); - - let local_ahead = shared_record - .append(vec![1, 2, 3]) - .encrypt::(&[0; 32]); - - assert_eq!(local_ahead.idx, 1); - - let local = vec![shared_record.clone(), local_ahead.clone()]; // local knows about the already synced, and something newer in the same store - let remote = vec![shared_record.clone(), remote_ahead.clone()]; // remote knows about the already-synced, and one new record in a new store - - let (store, diff) = build_test_diff(local, remote).await; - let operations = sync::operations(diff, &store).await.unwrap(); - - assert_eq!(operations.len(), 2); - - assert_eq!( - operations, - vec![ - // Or in otherwords, local is ahead by one - Operation::Upload { - host: local_ahead.host.id, - tag: local_ahead.tag, - local: 1, - remote: Some(0), - }, - // Or in other words, remote knows of a record in an entirely new store (tag) - Operation::Download { - host: remote_ahead.host.id, - tag: remote_ahead.tag, - local: None, - remote: 0, - }, - ] - ); - } - - #[tokio::test] - async fn build_complex_diff() { - // One shared, ahead but known only by remote - // One known only by local - // One known only by remote - - let shared_record = test_record(); - let local_only = test_record(); - - let local_only_20 = test_record(); - let local_only_21 = local_only_20 - .append(vec![1, 2, 3]) - .encrypt::(&[0; 32]); - let local_only_22 = local_only_21 - .append(vec![1, 2, 3]) - .encrypt::(&[0; 32]); - let local_only_23 = local_only_22 - .append(vec![1, 2, 3]) - .encrypt::(&[0; 32]); - - let remote_only = test_record(); - - let remote_only_20 = test_record(); - let remote_only_21 = remote_only_20 - .append(vec![2, 3, 2]) - .encrypt::(&[0; 32]); - let remote_only_22 = remote_only_21 - .append(vec![2, 3, 2]) - .encrypt::(&[0; 32]); - let remote_only_23 = remote_only_22 - .append(vec![2, 3, 2]) - .encrypt::(&[0; 32]); - let remote_only_24 = remote_only_23 - .append(vec![2, 3, 2]) - .encrypt::(&[0; 32]); - - let second_shared = test_record(); - let second_shared_remote_ahead = second_shared - .append(vec![1, 2, 3]) - .encrypt::(&[0; 32]); - let second_shared_remote_ahead2 = second_shared_remote_ahead - .append(vec![1, 2, 3]) - .encrypt::(&[0; 32]); - - let third_shared = test_record(); - let third_shared_local_ahead = third_shared - .append(vec![1, 2, 3]) - .encrypt::(&[0; 32]); - let third_shared_local_ahead2 = third_shared_local_ahead - .append(vec![1, 2, 3]) - .encrypt::(&[0; 32]); - - let fourth_shared = test_record(); - let fourth_shared_remote_ahead = fourth_shared - .append(vec![1, 2, 3]) - .encrypt::(&[0; 32]); - let fourth_shared_remote_ahead2 = fourth_shared_remote_ahead - .append(vec![1, 2, 3]) - .encrypt::(&[0; 32]); - - let local = vec![ - shared_record.clone(), - second_shared.clone(), - third_shared.clone(), - fourth_shared.clone(), - fourth_shared_remote_ahead.clone(), - // single store, only local has it - local_only.clone(), - // bigger store, also only known by local - local_only_20.clone(), - local_only_21.clone(), - local_only_22.clone(), - local_only_23.clone(), - // another shared store, but local is ahead on this one - third_shared_local_ahead.clone(), - third_shared_local_ahead2.clone(), - ]; - - let remote = vec![ - remote_only.clone(), - remote_only_20.clone(), - remote_only_21.clone(), - remote_only_22.clone(), - remote_only_23.clone(), - remote_only_24.clone(), - shared_record.clone(), - second_shared.clone(), - third_shared.clone(), - second_shared_remote_ahead.clone(), - second_shared_remote_ahead2.clone(), - fourth_shared.clone(), - fourth_shared_remote_ahead.clone(), - fourth_shared_remote_ahead2.clone(), - ]; // remote knows about the already-synced, and one new record in a new store - - let (store, diff) = build_test_diff(local, remote).await; - let operations = sync::operations(diff, &store).await.unwrap(); - - assert_eq!(operations.len(), 7); - - let mut result_ops = vec![ - // We started with a shared record, but the remote knows of two newer records in the - // same store - Operation::Download { - local: Some(0), - remote: 2, - host: second_shared_remote_ahead.host.id, - tag: second_shared_remote_ahead.tag, - }, - // We have a shared record, local knows of the first two but not the last - Operation::Download { - local: Some(1), - remote: 2, - host: fourth_shared_remote_ahead2.host.id, - tag: fourth_shared_remote_ahead2.tag, - }, - // Remote knows of a store with a single record that local does not have - Operation::Download { - local: None, - remote: 0, - host: remote_only.host.id, - tag: remote_only.tag, - }, - // Remote knows of a store with a bunch of records that local does not have - Operation::Download { - local: None, - remote: 4, - host: remote_only_20.host.id, - tag: remote_only_20.tag, - }, - // Local knows of a record in a store that remote does not have - Operation::Upload { - local: 0, - remote: None, - host: local_only.host.id, - tag: local_only.tag, - }, - // Local knows of 4 records in a store that remote does not have - Operation::Upload { - local: 3, - remote: None, - host: local_only_20.host.id, - tag: local_only_20.tag, - }, - // Local knows of 2 more records in a shared store that remote only has one of - Operation::Upload { - local: 2, - remote: Some(0), - host: third_shared.host.id, - tag: third_shared.tag, - }, - ]; - - result_ops.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()), - }); - - assert_eq!(result_ops, operations); - } -} diff --git a/atuin-client/src/secrets.rs b/atuin-client/src/secrets.rs deleted file mode 100644 index 21f015cd..00000000 --- a/atuin-client/src/secrets.rs +++ /dev/null @@ -1,59 +0,0 @@ -// This file will probably trigger a lot of scanners. Sorry. - -// A list of (name, regex, test), where test should match against regex -pub static SECRET_PATTERNS: &[(&str, &str, &str)] = &[ - ( - "AWS Access Key ID", - "AKIA[0-9A-Z]{16}", - "AKIAIOSFODNN7EXAMPLE", - ), - ( - "Atuin login", - r"atuin\s+login", - "atuin login -u mycoolusername -p mycoolpassword -k \"lots of random words\"", - ), - ( - "GitHub PAT (old)", - "ghp_[a-zA-Z0-9]{36}", - "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", // legit, I expired it - ), - ( - "GitHub PAT (new)", - "github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}", - "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", // also legit, also expired - ), - ( - "Slack OAuth v2 bot", - "xoxb-[0-9]{11}-[0-9]{11}-[0-9a-zA-Z]{24}", - "xoxb-17653672481-19874698323-pdFZKVeTuE8sk7oOcBrzbqgy", - ), - ( - "Slack OAuth v2 user token", - "xoxp-[0-9]{11}-[0-9]{11}-[0-9a-zA-Z]{24}", - "xoxp-17653672481-19874698323-pdFZKVeTuE8sk7oOcBrzbqgy", - ), - ( - "Slack webhook", - "T[a-zA-Z0-9_]{8}/B[a-zA-Z0-9_]{8}/[a-zA-Z0-9_]{24}", - "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX", - ), - ("Stripe test key", "sk_test_[0-9a-zA-Z]{24}", "sk_test_1234567890abcdefghijklmnop"), - ("Stripe live key", "sk_live_[0-9a-zA-Z]{24}", "sk_live_1234567890abcdefghijklmnop"), -]; - -#[cfg(test)] -mod tests { - use regex::Regex; - - use crate::secrets::SECRET_PATTERNS; - - #[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}")); - - assert!(re.is_match(test), "{name} test failed!"); - } - } -} diff --git a/atuin-client/src/settings.rs b/atuin-client/src/settings.rs deleted file mode 100644 index daf8fe34..00000000 --- a/atuin-client/src/settings.rs +++ /dev/null @@ -1,784 +0,0 @@ -use std::{ - collections::HashMap, - convert::TryFrom, - fmt, - io::prelude::*, - path::{Path, PathBuf}, - str::FromStr, -}; - -use atuin_common::record::HostId; -use clap::ValueEnum; -use config::{ - builder::DefaultState, Config, ConfigBuilder, Environment, File as ConfigFile, FileFormat, -}; -use eyre::{bail, eyre, Context, Error, Result}; -use fs_err::{create_dir_all, File}; -use parse_duration::parse; -use regex::RegexSet; -use semver::Version; -use serde::Deserialize; -use serde_with::DeserializeFromStr; -use time::{ - format_description::{well_known::Rfc3339, FormatItem}, - macros::format_description, - OffsetDateTime, UtcOffset, -}; -use uuid::Uuid; - -pub const HISTORY_PAGE_SIZE: i64 = 100; -pub const LAST_SYNC_FILENAME: &str = "last_sync_time"; -pub const LAST_VERSION_CHECK_FILENAME: &str = "last_version_check_time"; -pub const LATEST_VERSION_FILENAME: &str = "latest_version"; -pub const HOST_ID_FILENAME: &str = "host_id"; -static EXAMPLE_CONFIG: &str = include_str!("../config.toml"); - -mod dotfiles; - -#[derive(Clone, Debug, Deserialize, Copy, ValueEnum, PartialEq)] -pub enum SearchMode { - #[serde(rename = "prefix")] - Prefix, - - #[serde(rename = "fulltext")] - #[clap(aliases = &["fulltext"])] - FullText, - - #[serde(rename = "fuzzy")] - Fuzzy, - - #[serde(rename = "skim")] - Skim, -} - -impl SearchMode { - pub fn as_str(&self) -> &'static str { - match self { - SearchMode::Prefix => "PREFIX", - SearchMode::FullText => "FULLTXT", - SearchMode::Fuzzy => "FUZZY", - SearchMode::Skim => "SKIM", - } - } - pub fn next(&self, settings: &Settings) -> Self { - match self { - SearchMode::Prefix => SearchMode::FullText, - // if the user is using skim, we go to skim - SearchMode::FullText if settings.search_mode == SearchMode::Skim => SearchMode::Skim, - // otherwise fuzzy. - SearchMode::FullText => SearchMode::Fuzzy, - SearchMode::Fuzzy | SearchMode::Skim => SearchMode::Prefix, - } - } -} - -#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum)] -pub enum FilterMode { - #[serde(rename = "global")] - Global = 0, - - #[serde(rename = "host")] - Host = 1, - - #[serde(rename = "session")] - Session = 2, - - #[serde(rename = "directory")] - Directory = 3, - - #[serde(rename = "workspace")] - Workspace = 4, -} - -impl FilterMode { - pub fn as_str(&self) -> &'static str { - match self { - FilterMode::Global => "GLOBAL", - FilterMode::Host => "HOST", - FilterMode::Session => "SESSION", - FilterMode::Directory => "DIRECTORY", - FilterMode::Workspace => "WORKSPACE", - } - } -} - -#[derive(Clone, Debug, Deserialize, Copy)] -pub enum ExitMode { - #[serde(rename = "return-original")] - ReturnOriginal, - - #[serde(rename = "return-query")] - ReturnQuery, -} - -// FIXME: Can use upstream Dialect enum if https://github.com/stevedonovan/chrono-english/pull/16 is merged -// FIXME: Above PR was merged, but dependency was changed to interim (fork of chrono-english) in the ... interim -#[derive(Clone, Debug, Deserialize, Copy)] -pub enum Dialect { - #[serde(rename = "us")] - Us, - - #[serde(rename = "uk")] - Uk, -} - -impl From for interim::Dialect { - fn from(d: Dialect) -> interim::Dialect { - match d { - Dialect::Uk => interim::Dialect::Uk, - Dialect::Us => interim::Dialect::Us, - } - } -} - -/// Type wrapper around `time::UtcOffset` to support a wider variety of timezone formats. -/// -/// Note that the parsing of this struct needs to be done before starting any -/// multithreaded runtime, otherwise it will fail on most Unix systems. -/// -/// See: https://github.com/atuinsh/atuin/pull/1517#discussion_r1447516426 -#[derive(Clone, Copy, Debug, Eq, PartialEq, DeserializeFromStr)] -pub struct Timezone(pub UtcOffset); -impl fmt::Display for Timezone { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(f) - } -} -/// format: <+|->[:[:]] -static OFFSET_FMT: &[FormatItem<'_>] = - format_description!("[offset_hour sign:mandatory padding:none][optional [:[offset_minute padding:none][optional [:[offset_second padding:none]]]]]"); -impl FromStr for Timezone { - type Err = Error; - - fn from_str(s: &str) -> Result { - // local timezone - if matches!(s.to_lowercase().as_str(), "l" | "local") { - // There have been some timezone issues, related to errors fetching it on some - // platforms - // Rather than fail to start, fallback to UTC. The user should still be able to specify - // their timezone manually in the config file. - let offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC); - return Ok(Self(offset)); - } - - if matches!(s.to_lowercase().as_str(), "0" | "utc") { - let offset = UtcOffset::UTC; - return Ok(Self(offset)); - } - - // offset from UTC - if let Ok(offset) = UtcOffset::parse(s, OFFSET_FMT) { - return Ok(Self(offset)); - } - - // IDEA: Currently named timezones are not supported, because the well-known crate - // for this is `chrono_tz`, which is not really interoperable with the datetime crate - // that we currently use - `time`. If ever we migrate to using `chrono`, this would - // be a good feature to add. - - bail!(r#""{s}" is not a valid timezone spec"#) - } -} - -#[derive(Clone, Debug, Deserialize, Copy)] -pub enum Style { - #[serde(rename = "auto")] - Auto, - - #[serde(rename = "full")] - Full, - - #[serde(rename = "compact")] - Compact, -} - -#[derive(Clone, Debug, Deserialize, Copy)] -pub enum WordJumpMode { - #[serde(rename = "emacs")] - Emacs, - - #[serde(rename = "subl")] - Subl, -} - -#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum)] -pub enum KeymapMode { - #[serde(rename = "emacs")] - Emacs, - - #[serde(rename = "vim-normal")] - VimNormal, - - #[serde(rename = "vim-insert")] - VimInsert, - - #[serde(rename = "auto")] - Auto, -} - -impl KeymapMode { - pub fn as_str(&self) -> &'static str { - match self { - KeymapMode::Emacs => "EMACS", - KeymapMode::VimNormal => "VIMNORMAL", - KeymapMode::VimInsert => "VIMINSERT", - KeymapMode::Auto => "AUTO", - } - } -} - -// We want to translate the config to crossterm::cursor::SetCursorStyle, but -// the original type does not implement trait serde::Deserialize unfortunately. -// It seems impossible to implement Deserialize for external types when it is -// used in HashMap (https://stackoverflow.com/questions/67142663). We instead -// define an adapter type. -#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum)] -pub enum CursorStyle { - #[serde(rename = "default")] - DefaultUserShape, - - #[serde(rename = "blink-block")] - BlinkingBlock, - - #[serde(rename = "steady-block")] - SteadyBlock, - - #[serde(rename = "blink-underline")] - BlinkingUnderScore, - - #[serde(rename = "steady-underline")] - SteadyUnderScore, - - #[serde(rename = "blink-bar")] - BlinkingBar, - - #[serde(rename = "steady-bar")] - SteadyBar, -} - -impl CursorStyle { - pub fn as_str(&self) -> &'static str { - match self { - CursorStyle::DefaultUserShape => "DEFAULT", - CursorStyle::BlinkingBlock => "BLINKBLOCK", - CursorStyle::SteadyBlock => "STEADYBLOCK", - CursorStyle::BlinkingUnderScore => "BLINKUNDERLINE", - CursorStyle::SteadyUnderScore => "STEADYUNDERLINE", - CursorStyle::BlinkingBar => "BLINKBAR", - CursorStyle::SteadyBar => "STEADYBAR", - } - } -} - -#[derive(Clone, Debug, Deserialize)] -pub struct Stats { - #[serde(default = "Stats::common_prefix_default")] - pub common_prefix: Vec, // sudo, etc. commands we want to strip off - #[serde(default = "Stats::common_subcommands_default")] - pub common_subcommands: Vec, // kubectl, commands we should consider subcommands for - #[serde(default = "Stats::ignored_commands_default")] - pub ignored_commands: Vec, // cd, ls, etc. commands we want to completely hide from stats -} - -impl Stats { - fn common_prefix_default() -> Vec { - vec!["sudo", "doas"].into_iter().map(String::from).collect() - } - - fn common_subcommands_default() -> Vec { - vec![ - "apt", - "cargo", - "composer", - "dnf", - "docker", - "git", - "go", - "ip", - "kubectl", - "nix", - "nmcli", - "npm", - "pecl", - "pnpm", - "podman", - "port", - "systemctl", - "tmux", - "yarn", - ] - .into_iter() - .map(String::from) - .collect() - } - - fn ignored_commands_default() -> Vec { - vec![] - } -} - -impl Default for Stats { - fn default() -> Self { - Self { - common_prefix: Self::common_prefix_default(), - common_subcommands: Self::common_subcommands_default(), - ignored_commands: Self::ignored_commands_default(), - } - } -} - -#[derive(Clone, Debug, Deserialize, Default)] -pub struct Sync { - pub records: bool, -} - -#[derive(Clone, Debug, Deserialize, Default)] -pub struct Keys { - pub scroll_exits: bool, -} - -#[derive(Clone, Debug, Deserialize)] -pub struct Settings { - pub dialect: Dialect, - pub timezone: Timezone, - pub style: Style, - pub auto_sync: bool, - pub update_check: bool, - pub sync_address: String, - pub sync_frequency: String, - pub db_path: String, - pub record_store_path: String, - pub key_path: String, - pub session_path: String, - pub search_mode: SearchMode, - pub filter_mode: FilterMode, - pub filter_mode_shell_up_key_binding: Option, - pub search_mode_shell_up_key_binding: Option, - pub shell_up_key_binding: bool, - pub inline_height: u16, - pub invert: bool, - pub show_preview: bool, - pub show_preview_auto: bool, - pub max_preview_height: u16, - pub show_help: bool, - pub show_tabs: bool, - pub exit_mode: ExitMode, - pub keymap_mode: KeymapMode, - pub keymap_mode_shell: KeymapMode, - pub keymap_cursor: HashMap, - pub word_jump_mode: WordJumpMode, - pub word_chars: String, - pub scroll_context_lines: usize, - pub history_format: String, - pub prefers_reduced_motion: bool, - pub store_failed: bool, - - #[serde(with = "serde_regex", default = "RegexSet::empty")] - pub history_filter: RegexSet, - - #[serde(with = "serde_regex", default = "RegexSet::empty")] - pub cwd_filter: RegexSet, - - pub secrets_filter: bool, - pub workspaces: bool, - pub ctrl_n_shortcuts: bool, - - pub network_connect_timeout: u64, - pub network_timeout: u64, - pub local_timeout: f64, - pub enter_accept: bool, - pub smart_sort: bool, - - #[serde(default)] - pub stats: Stats, - - #[serde(default)] - pub sync: Sync, - - #[serde(default)] - pub keys: Keys, - - #[serde(default)] - pub dotfiles: dotfiles::Settings, - - // This is automatically loaded when settings is created. Do not set in - // config! Keep secrets and settings apart. - #[serde(skip)] - pub session_token: String, -} - -impl Settings { - pub fn utc() -> Self { - Self::builder() - .expect("Could not build default") - .set_override("timezone", "0") - .expect("failed to override timezone with UTC") - .build() - .expect("Could not build config") - .try_deserialize() - .expect("Could not deserialize config") - } - - fn save_to_data_dir(filename: &str, value: &str) -> Result<()> { - let data_dir = atuin_common::utils::data_dir(); - let data_dir = data_dir.as_path(); - - let path = data_dir.join(filename); - - fs_err::write(path, value)?; - - Ok(()) - } - - fn read_from_data_dir(filename: &str) -> Option { - let data_dir = atuin_common::utils::data_dir(); - let data_dir = data_dir.as_path(); - - let path = data_dir.join(filename); - - if !path.exists() { - return None; - } - - let value = fs_err::read_to_string(path); - - value.ok() - } - - fn save_current_time(filename: &str) -> Result<()> { - Settings::save_to_data_dir( - filename, - OffsetDateTime::now_utc().format(&Rfc3339)?.as_str(), - )?; - - Ok(()) - } - - fn load_time_from_file(filename: &str) -> Result { - let value = Settings::read_from_data_dir(filename); - - match value { - Some(v) => Ok(OffsetDateTime::parse(v.as_str(), &Rfc3339)?), - None => Ok(OffsetDateTime::UNIX_EPOCH), - } - } - - pub fn save_sync_time() -> Result<()> { - Settings::save_current_time(LAST_SYNC_FILENAME) - } - - pub fn save_version_check_time() -> Result<()> { - Settings::save_current_time(LAST_VERSION_CHECK_FILENAME) - } - - pub fn last_sync() -> Result { - Settings::load_time_from_file(LAST_SYNC_FILENAME) - } - - pub fn last_version_check() -> Result { - Settings::load_time_from_file(LAST_VERSION_CHECK_FILENAME) - } - - pub fn host_id() -> Option { - let id = Settings::read_from_data_dir(HOST_ID_FILENAME); - - if let Some(id) = id { - let parsed = - Uuid::from_str(id.as_str()).expect("failed to parse host ID from local directory"); - return Some(HostId(parsed)); - } - - let uuid = atuin_common::utils::uuid_v7(); - - Settings::save_to_data_dir(HOST_ID_FILENAME, uuid.as_simple().to_string().as_ref()) - .expect("Could not write host ID to data dir"); - - Some(HostId(uuid)) - } - - pub fn should_sync(&self) -> Result { - if !self.auto_sync || !PathBuf::from(self.session_path.as_str()).exists() { - return Ok(false); - } - - match parse(self.sync_frequency.as_str()) { - Ok(d) => { - let d = time::Duration::try_from(d).unwrap(); - Ok(OffsetDateTime::now_utc() - Settings::last_sync()? >= d) - } - Err(e) => Err(eyre!("failed to check sync: {}", e)), - } - } - - #[cfg(feature = "check-update")] - fn needs_update_check(&self) -> Result { - let last_check = Settings::last_version_check()?; - let diff = OffsetDateTime::now_utc() - last_check; - - // Check a max of once per hour - Ok(diff.whole_hours() >= 1) - } - - #[cfg(feature = "check-update")] - async fn latest_version(&self) -> Result { - // Default to the current version, and if that doesn't parse, a version so high it's unlikely to ever - // suggest upgrading. - let current = - Version::parse(env!("CARGO_PKG_VERSION")).unwrap_or(Version::new(100000, 0, 0)); - - if !self.needs_update_check()? { - // Worst case, we don't want Atuin to fail to start because something funky is going on with - // version checking. - let version = tokio::task::spawn_blocking(|| { - Settings::read_from_data_dir(LATEST_VERSION_FILENAME) - }) - .await - .expect("file task panicked"); - - let version = match version { - Some(v) => Version::parse(&v).unwrap_or(current), - None => current, - }; - - return Ok(version); - } - - #[cfg(feature = "sync")] - let latest = crate::api_client::latest_version().await.unwrap_or(current); - - #[cfg(not(feature = "sync"))] - let latest = current; - - let latest_encoded = latest.to_string(); - tokio::task::spawn_blocking(move || { - Settings::save_version_check_time()?; - Settings::save_to_data_dir(LATEST_VERSION_FILENAME, &latest_encoded)?; - Ok::<(), eyre::Report>(()) - }) - .await - .expect("file task panicked")?; - - Ok(latest) - } - - // Return Some(latest version) if an update is needed. Otherwise, none. - #[cfg(feature = "check-update")] - pub async fn needs_update(&self) -> Option { - if !self.update_check { - return None; - } - - let current = - Version::parse(env!("CARGO_PKG_VERSION")).unwrap_or(Version::new(100000, 0, 0)); - - let latest = self.latest_version().await; - - if latest.is_err() { - return None; - } - - let latest = latest.unwrap(); - - if latest > current { - return Some(latest); - } - - None - } - - #[cfg(not(feature = "check-update"))] - pub async fn needs_update(&self) -> Option { - None - } - - pub fn builder() -> Result> { - let data_dir = atuin_common::utils::data_dir(); - let db_path = data_dir.join("history.db"); - let record_store_path = data_dir.join("records.db"); - - let key_path = data_dir.join("key"); - let session_path = data_dir.join("session"); - - Ok(Config::builder() - .set_default("history_format", "{time}\t{command}\t{duration}")? - .set_default("db_path", db_path.to_str())? - .set_default("record_store_path", record_store_path.to_str())? - .set_default("key_path", key_path.to_str())? - .set_default("session_path", session_path.to_str())? - .set_default("dialect", "us")? - .set_default("timezone", "local")? - .set_default("auto_sync", true)? - .set_default("update_check", cfg!(feature = "check-update"))? - .set_default("sync_address", "https://api.atuin.sh")? - .set_default("sync_frequency", "10m")? - .set_default("search_mode", "fuzzy")? - .set_default("filter_mode", "global")? - .set_default("style", "auto")? - .set_default("inline_height", 0)? - .set_default("show_preview", false)? - .set_default("show_preview_auto", true)? - .set_default("max_preview_height", 4)? - .set_default("show_help", true)? - .set_default("show_tabs", true)? - .set_default("invert", false)? - .set_default("exit_mode", "return-original")? - .set_default("word_jump_mode", "emacs")? - .set_default( - "word_chars", - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", - )? - .set_default("scroll_context_lines", 1)? - .set_default("shell_up_key_binding", false)? - .set_default("session_token", "")? - .set_default("workspaces", false)? - .set_default("ctrl_n_shortcuts", false)? - .set_default("secrets_filter", true)? - .set_default("network_connect_timeout", 5)? - .set_default("network_timeout", 30)? - .set_default("local_timeout", 2.0)? - // enter_accept defaults to false here, but true in the default config file. The dissonance is - // intentional! - // Existing users will get the default "False", so we don't mess with any potential - // muscle memory. - // New users will get the new default, that is more similar to what they are used to. - .set_default("enter_accept", false)? - .set_default("sync.records", false)? - .set_default("keys.scroll_exits", true)? - .set_default("keymap_mode", "emacs")? - .set_default("keymap_mode_shell", "auto")? - .set_default("keymap_cursor", HashMap::::new())? - .set_default("smart_sort", false)? - .set_default("store_failed", true)? - .set_default( - "prefers_reduced_motion", - std::env::var("NO_MOTION") - .ok() - .map(|_| config::Value::new(None, config::ValueKind::Boolean(true))) - .unwrap_or_else(|| config::Value::new(None, config::ValueKind::Boolean(false))), - )? - .add_source( - Environment::with_prefix("atuin") - .prefix_separator("_") - .separator("__"), - )) - } - - pub fn new() -> Result { - let config_dir = atuin_common::utils::config_dir(); - let data_dir = atuin_common::utils::data_dir(); - - create_dir_all(&config_dir) - .wrap_err_with(|| format!("could not create dir {config_dir:?}"))?; - - create_dir_all(&data_dir).wrap_err_with(|| format!("could not create dir {data_dir:?}"))?; - - let mut config_file = if let Ok(p) = std::env::var("ATUIN_CONFIG_DIR") { - PathBuf::from(p) - } else { - let mut config_file = PathBuf::new(); - config_file.push(config_dir); - config_file - }; - - config_file.push("config.toml"); - - let mut config_builder = Self::builder()?; - - config_builder = if config_file.exists() { - config_builder.add_source(ConfigFile::new( - config_file.to_str().unwrap(), - FileFormat::Toml, - )) - } else { - let mut file = File::create(config_file).wrap_err("could not create config file")?; - file.write_all(EXAMPLE_CONFIG.as_bytes()) - .wrap_err("could not write default config file")?; - - config_builder - }; - - let config = config_builder.build()?; - let mut settings: Settings = config - .try_deserialize() - .map_err(|e| eyre!("failed to deserialize: {}", e))?; - - // all paths should be expanded - let db_path = settings.db_path; - let db_path = shellexpand::full(&db_path)?; - settings.db_path = db_path.to_string(); - - let key_path = settings.key_path; - let key_path = shellexpand::full(&key_path)?; - settings.key_path = key_path.to_string(); - - let session_path = settings.session_path; - let session_path = shellexpand::full(&session_path)?; - settings.session_path = session_path.to_string(); - - // Finally, set the auth token - if Path::new(session_path.to_string().as_str()).exists() { - let token = fs_err::read_to_string(session_path.to_string())?; - settings.session_token = token.trim().to_string(); - } else { - settings.session_token = String::from("not logged in"); - } - - Ok(settings) - } - - pub fn example_config() -> &'static str { - EXAMPLE_CONFIG - } -} - -impl Default for Settings { - fn default() -> Self { - // if this panics something is very wrong, as the default config - // does not build or deserialize into the settings struct - Self::builder() - .expect("Could not build default") - .build() - .expect("Could not build config") - .try_deserialize() - .expect("Could not deserialize config") - } -} - -#[cfg(test)] -mod tests { - use std::str::FromStr; - - use eyre::Result; - - use super::Timezone; - - #[test] - fn can_parse_offset_timezone_spec() -> Result<()> { - assert_eq!(Timezone::from_str("+02")?.0.as_hms(), (2, 0, 0)); - assert_eq!(Timezone::from_str("-04")?.0.as_hms(), (-4, 0, 0)); - assert_eq!(Timezone::from_str("+05:30")?.0.as_hms(), (5, 30, 0)); - assert_eq!(Timezone::from_str("-09:30")?.0.as_hms(), (-9, -30, 0)); - - // single digit hours are allowed - assert_eq!(Timezone::from_str("+2")?.0.as_hms(), (2, 0, 0)); - assert_eq!(Timezone::from_str("-4")?.0.as_hms(), (-4, 0, 0)); - assert_eq!(Timezone::from_str("+5:30")?.0.as_hms(), (5, 30, 0)); - assert_eq!(Timezone::from_str("-9:30")?.0.as_hms(), (-9, -30, 0)); - - // fully qualified form - assert_eq!(Timezone::from_str("+09:30:00")?.0.as_hms(), (9, 30, 0)); - assert_eq!(Timezone::from_str("-09:30:00")?.0.as_hms(), (-9, -30, 0)); - - // these offsets don't really exist but are supported anyway - assert_eq!(Timezone::from_str("+0:5")?.0.as_hms(), (0, 5, 0)); - assert_eq!(Timezone::from_str("-0:5")?.0.as_hms(), (0, -5, 0)); - assert_eq!(Timezone::from_str("+01:23:45")?.0.as_hms(), (1, 23, 45)); - assert_eq!(Timezone::from_str("-01:23:45")?.0.as_hms(), (-1, -23, -45)); - - // require a leading sign for clarity - assert!(Timezone::from_str("5").is_err()); - assert!(Timezone::from_str("10:30").is_err()); - - Ok(()) - } -} diff --git a/atuin-client/src/settings/dotfiles.rs b/atuin-client/src/settings/dotfiles.rs deleted file mode 100644 index dd852781..00000000 --- a/atuin-client/src/settings/dotfiles.rs +++ /dev/null @@ -1,6 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Serialize, Deserialize, Clone, Default)] -pub struct Settings { - pub enabled: bool, -} diff --git a/atuin-client/src/sync.rs b/atuin-client/src/sync.rs deleted file mode 100644 index 1f0d3dd8..00000000 --- a/atuin-client/src/sync.rs +++ /dev/null @@ -1,210 +0,0 @@ -use std::collections::HashSet; -use std::convert::TryInto; -use std::iter::FromIterator; - -use eyre::Result; - -use atuin_common::api::AddHistoryRequest; -use crypto_secretbox::Key; -use time::OffsetDateTime; - -use crate::{ - api_client, - database::Database, - encryption::{decrypt, encrypt, load_key}, - settings::Settings, -}; - -pub fn hash_str(string: &str) -> String { - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(string.as_bytes()); - hex::encode(hasher.finalize()) -} - -// Currently sync is kinda naive, and basically just pages backwards through -// history. This means newly added stuff shows up properly! We also just use -// the total count in each database to indicate whether a sync is needed. -// I think this could be massively improved! If we had a way of easily -// indicating count per time period (hour, day, week, year, etc) then we can -// easily pinpoint where we are missing data and what needs downloading. Start -// with year, then find the week, then the day, then the hour, then download it -// all! The current naive approach will do for now. - -// Check if remote has things we don't, and if so, download them. -// Returns (num downloaded, total local) -async fn sync_download( - key: &Key, - force: bool, - client: &api_client::Client<'_>, - db: &(impl Database + Send), -) -> Result<(i64, i64)> { - debug!("starting sync download"); - - let remote_status = client.status().await?; - let remote_count = remote_status.count; - - // useful to ensure we don't even save something that hasn't yet been synced + deleted - let remote_deleted = - HashSet::<&str>::from_iter(remote_status.deleted.iter().map(String::as_str)); - - let initial_local = db.history_count(true).await?; - let mut local_count = initial_local; - - let mut last_sync = if force { - OffsetDateTime::UNIX_EPOCH - } else { - Settings::last_sync()? - }; - - let mut last_timestamp = OffsetDateTime::UNIX_EPOCH; - - let host = if force { Some(String::from("")) } else { None }; - - while remote_count > local_count { - let page = client - .get_history(last_sync, last_timestamp, host.clone()) - .await?; - - let history: Vec<_> = page - .history - .iter() - // TODO: handle deletion earlier in this chain - .map(|h| serde_json::from_str(h).expect("invalid base64")) - .map(|h| decrypt(h, key).expect("failed to decrypt history! check your key")) - .map(|mut h| { - if remote_deleted.contains(h.id.0.as_str()) { - h.deleted_at = Some(time::OffsetDateTime::now_utc()); - h.command = String::from(""); - } - - h - }) - .collect(); - - db.save_bulk(&history).await?; - - local_count = db.history_count(true).await?; - - if history.len() < remote_status.page_size.try_into().unwrap() { - break; - } - - let page_last = history - .last() - .expect("could not get last element of page") - .timestamp; - - // in the case of a small sync frequency, it's possible for history to - // be "lost" between syncs. In this case we need to rewind the sync - // timestamps - if page_last == last_timestamp { - last_timestamp = OffsetDateTime::UNIX_EPOCH; - last_sync -= time::Duration::hours(1); - } else { - last_timestamp = page_last; - } - } - - for i in remote_status.deleted { - // we will update the stored history to have this data - // pretty much everything can be nullified - if let Some(h) = db.load(i.as_str()).await? { - db.delete(h).await?; - } else { - info!( - "could not delete history with id {}, not found locally", - i.as_str() - ); - } - } - - Ok((local_count - initial_local, local_count)) -} - -// Check if we have things remote doesn't, and if so, upload them -async fn sync_upload( - key: &Key, - _force: bool, - client: &api_client::Client<'_>, - db: &(impl Database + Send), -) -> Result<()> { - debug!("starting sync upload"); - - let remote_status = client.status().await?; - let remote_deleted: HashSet = HashSet::from_iter(remote_status.deleted.clone()); - - let initial_remote_count = client.count().await?; - let mut remote_count = initial_remote_count; - - let local_count = db.history_count(true).await?; - - debug!("remote has {}, we have {}", remote_count, local_count); - - // first just try the most recent set - let mut cursor = OffsetDateTime::now_utc(); - - while local_count > remote_count { - let last = db.before(cursor, remote_status.page_size).await?; - let mut buffer = Vec::new(); - - if last.is_empty() { - break; - } - - for i in last { - let data = encrypt(&i, key)?; - let data = serde_json::to_string(&data)?; - - let add_hist = AddHistoryRequest { - id: i.id.to_string(), - timestamp: i.timestamp, - data, - hostname: hash_str(&i.hostname), - }; - - buffer.push(add_hist); - } - - // anything left over outside of the 100 block size - client.post_history(&buffer).await?; - cursor = buffer.last().unwrap().timestamp; - remote_count = client.count().await?; - - debug!("upload cursor: {:?}", cursor); - } - - let deleted = db.deleted().await?; - - for i in deleted { - if remote_deleted.contains(&i.id.to_string()) { - continue; - } - - info!("deleting {} on remote", i.id); - client.delete_history(i).await?; - } - - Ok(()) -} - -pub async fn sync(settings: &Settings, force: bool, db: &(impl Database + Send)) -> Result<()> { - let client = api_client::Client::new( - &settings.sync_address, - &settings.session_token, - settings.network_connect_timeout, - settings.network_timeout, - )?; - - Settings::save_sync_time()?; - - let key = load_key(settings)?; // encryption key - - sync_upload(&key, force, &client, db).await?; - - let download = sync_download(&key, force, &client, db).await?; - - debug!("sync downloaded {}", download.0); - - Ok(()) -} diff --git a/atuin-client/src/utils.rs b/atuin-client/src/utils.rs deleted file mode 100644 index a7c6eab0..00000000 --- a/atuin-client/src/utils.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub(crate) fn get_hostname() -> String { - std::env::var("ATUIN_HOST_NAME").unwrap_or_else(|_| { - whoami::fallible::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()) -} - -/// Returns a pair of the hostname and username, separated by a colon. -pub(crate) fn get_host_user() -> String { - format!("{}:{}", get_hostname(), get_username()) -} diff --git a/atuin-client/tests/data/xonsh-history.sqlite b/atuin-client/tests/data/xonsh-history.sqlite deleted file mode 100644 index 744fcf86..00000000 Binary files a/atuin-client/tests/data/xonsh-history.sqlite and /dev/null differ diff --git a/atuin-client/tests/data/xonsh/xonsh-82eafbf5-9f43-489a-80d2-61c7dc6ef542.json b/atuin-client/tests/data/xonsh/xonsh-82eafbf5-9f43-489a-80d2-61c7dc6ef542.json deleted file mode 100644 index 339a09f1..00000000 --- a/atuin-client/tests/data/xonsh/xonsh-82eafbf5-9f43-489a-80d2-61c7dc6ef542.json +++ /dev/null @@ -1,12 +0,0 @@ -{"locs": [ 69, 3371, 3451, 3978], - "index": {"offsets":{"__total__":0,"cmds":[{"__total__":10,"cwd":18,"inp":78,"rtn":96,"ts":[106,125,105]},{"__total__":149,"cwd":157,"inp":217,"rtn":234,"ts":[244,263,243]},9],"env":{"ATUIN_SESSION":314,"BASH_COMPLETIONS":370,"COLORTERM":433,"DBUS_SESSION_BUS_ADDRESS":474,"DESKTOP_SESSION":529,"DISPLAY":550,"GDMSESSION":570,"GIO_LAUNCHED_DESKTOP_FILE":609,"GIO_LAUNCHED_DESKTOP_FILE_PID":704,"GJS_DEBUG_OUTPUT":734,"GJS_DEBUG_TOPICS":764,"GNOME_DESKTOP_SESSION_ID":811,"GNOME_SETUP_DISPLAY":856,"GNOME_SHELL_SESSION_MODE":890,"GTK_MODULES":915,"HOME":942,"IM_CONFIG_PHASE":976,"INVOCATION_ID":998,"JOURNAL_STREAM":1052,"LANG":1071,"LOGNAME":1097,"MANAGERPID":1118,"MOZ_ENABLE_WAYLAND":1148,"PATH":1161,"PWD":1736,"PYENV_DIR":1802,"PYENV_HOOK_PATH":1874,"PYENV_ROOT":2048,"PYENV_SHELL":2086,"PYENV_VERSION":2111,"QT_ACCESSIBILITY":2141,"QT_IM_MODULE":2162,"SESSION_MANAGER":2189,"SHELL":2279,"SHLVL":2303,"SSH_AGENT_LAUNCHER":2330,"SSH_AUTH_SOCK":2364,"SSL_CERT_DIR":2415,"SSL_CERT_FILE":2458,"SYSTEMD_EXEC_PID":2525,"TERM":2541,"TERM_PROGRAM":2575,"TERM_PROGRAM_VERSION":2610,"THREAD_SUBPROCS":2657,"USER":2670,"USERNAME":2689,"WAYLAND_DISPLAY":2715,"WEZTERM_CONFIG_DIR":2750,"WEZTERM_CONFIG_FILE":2806,"WEZTERM_EXECUTABLE":2874,"WEZTERM_EXECUTABLE_DIR":2927,"WEZTERM_PANE":2957,"WEZTERM_UNIX_SOCKET":2986,"XAUTHORITY":3047,"XDG_CONFIG_DIRS":3116,"XDG_CURRENT_DESKTOP":3176,"XDG_DATA_DIRS":3209,"XDG_MENU_PREFIX":3316,"XDG_RUNTIME_DIR":3345,"XDG_SESSION_CLASS":3387,"XDG_SESSION_DESKTOP":3418,"XDG_SESSION_TYPE":3448,"XMODIFIERS":3473,"XONSHRC":3496,"XONSHRC_DIR":3594,"XONSH_CAPTURE_ALWAYS":3674,"XONSH_CONFIG_DIR":3698,"XONSH_DATA_DIR":3747,"XONSH_INTERACTIVE":3805,"XONSH_LOGIN":3825,"XONSH_VERSION":3847,"__total__":296},"locked":3869,"sessionid":3889,"ts":[3936,3956,3935]},"sizes":{"__total__":3978,"cmds":[{"__total__":137,"cwd":51,"inp":9,"rtn":1,"ts":[17,18,40]},{"__total__":136,"cwd":51,"inp":8,"rtn":1,"ts":[17,18,40]},278],"env":{"ATUIN_SESSION":34,"BASH_COMPLETIONS":48,"COLORTERM":11,"DBUS_SESSION_BUS_ADDRESS":34,"DESKTOP_SESSION":8,"DISPLAY":4,"GDMSESSION":8,"GIO_LAUNCHED_DESKTOP_FILE":60,"GIO_LAUNCHED_DESKTOP_FILE_PID":8,"GJS_DEBUG_OUTPUT":8,"GJS_DEBUG_TOPICS":17,"GNOME_DESKTOP_SESSION_ID":20,"GNOME_SETUP_DISPLAY":4,"GNOME_SHELL_SESSION_MODE":8,"GTK_MODULES":17,"HOME":13,"IM_CONFIG_PHASE":3,"INVOCATION_ID":34,"JOURNAL_STREAM":9,"LANG":13,"LOGNAME":5,"MANAGERPID":6,"MOZ_ENABLE_WAYLAND":3,"PATH":566,"PWD":51,"PYENV_DIR":51,"PYENV_HOOK_PATH":158,"PYENV_ROOT":21,"PYENV_SHELL":6,"PYENV_VERSION":8,"QT_ACCESSIBILITY":3,"QT_IM_MODULE":6,"SESSION_MANAGER":79,"SHELL":13,"SHLVL":3,"SSH_AGENT_LAUNCHER":15,"SSH_AUTH_SOCK":33,"SSL_CERT_DIR":24,"SSL_CERT_FILE":45,"SYSTEMD_EXEC_PID":6,"TERM":16,"TERM_PROGRAM":9,"TERM_PROGRAM_VERSION":26,"THREAD_SUBPROCS":3,"USER":5,"USERNAME":5,"WAYLAND_DISPLAY":11,"WEZTERM_CONFIG_DIR":31,"WEZTERM_CONFIG_FILE":44,"WEZTERM_EXECUTABLE":25,"WEZTERM_EXECUTABLE_DIR":12,"WEZTERM_PANE":4,"WEZTERM_UNIX_SOCKET":45,"XAUTHORITY":48,"XDG_CONFIG_DIRS":35,"XDG_CURRENT_DESKTOP":14,"XDG_DATA_DIRS":86,"XDG_MENU_PREFIX":8,"XDG_RUNTIME_DIR":19,"XDG_SESSION_CLASS":6,"XDG_SESSION_DESKTOP":8,"XDG_SESSION_TYPE":9,"XMODIFIERS":10,"XONSHRC":81,"XONSHRC_DIR":54,"XONSH_CAPTURE_ALWAYS":2,"XONSH_CONFIG_DIR":29,"XONSH_DATA_DIR":35,"XONSH_INTERACTIVE":3,"XONSH_LOGIN":3,"XONSH_VERSION":8,"__total__":3561},"locked":5,"sessionid":38,"ts":[18,18,41]}}, - "data": {"cmds": [{"cwd": "\/home\/user\/Documents\/code\/atuin\/atuin-client", "inp": "false\n", "rtn": 1, "ts": [1707241291.142516, 1707241291.1527853] -} -, {"cwd": "\/home\/user\/Documents\/code\/atuin\/atuin-client", "inp": "exit\n", "rtn": 0, "ts": [1707241292.271584, 1707241292.2758434] -} -] -, "env": {"ATUIN_SESSION": "018d7f82ad167dc4888ca0bf294d2bfd", "BASH_COMPLETIONS": "\/usr\/share\/bash-completion\/bash_completion", "COLORTERM": "truecolor", "DBUS_SESSION_BUS_ADDRESS": "unix:path=\/run\/user\/1000\/bus", "DESKTOP_SESSION": "ubuntu", "DISPLAY": ":0", "GDMSESSION": "ubuntu", "GIO_LAUNCHED_DESKTOP_FILE": "\/usr\/share\/applications\/org.wezfurlong.wezterm.desktop", "GIO_LAUNCHED_DESKTOP_FILE_PID": "196859", "GJS_DEBUG_OUTPUT": "stderr", "GJS_DEBUG_TOPICS": "JS ERROR;JS LOG", "GNOME_DESKTOP_SESSION_ID": "this-is-deprecated", "GNOME_SETUP_DISPLAY": ":1", "GNOME_SHELL_SESSION_MODE": "ubuntu", "GTK_MODULES": "gail:atk-bridge", "HOME": "\/home\/user", "IM_CONFIG_PHASE": "1", "INVOCATION_ID": "4f121e7ad56c41a6b84aa3cbe1ad61fa", "JOURNAL_STREAM": "8:37187", "LANG": "en_US.UTF-8", "LOGNAME": "user", "MANAGERPID": "2118", "MOZ_ENABLE_WAYLAND": "1", "PATH": "\/home\/user\/.pyenv\/versions\/3.12.0\/bin:\/home\/user\/.pyenv\/libexec:\/home\/user\/.pyenv\/plugins\/python-build\/bin:\/home\/user\/.pyenv\/plugins\/pyenv-virtualenv\/bin:\/home\/user\/.pyenv\/plugins\/pyenv-update\/bin:\/home\/user\/.pyenv\/plugins\/pyenv-doctor\/bin:\/home\/user\/.cargo\/bin:\/home\/user\/.pyenv\/shims:\/home\/user\/.pyenv\/bin:\/home\/user\/bin:\/home\/user\/bin:\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin:\/usr\/games:\/usr\/local\/games:\/snap\/bin:\/snap\/bin:\/home\/user\/.local\/share\/JetBrains\/Toolbox\/scripts", "PWD": "\/home\/user\/Documents\/code\/atuin\/atuin-client", "PYENV_DIR": "\/home\/user\/Documents\/code\/atuin\/atuin-client", "PYENV_HOOK_PATH": "\/home\/user\/.pyenv\/pyenv.d:\/usr\/local\/etc\/pyenv.d:\/etc\/pyenv.d:\/usr\/lib\/pyenv\/hooks:\/home\/user\/.pyenv\/plugins\/pyenv-virtualenv\/etc\/pyenv.d", "PYENV_ROOT": "\/home\/user\/.pyenv", "PYENV_SHELL": "bash", "PYENV_VERSION": "3.12.0", "QT_ACCESSIBILITY": "1", "QT_IM_MODULE": "ibus", "SESSION_MANAGER": "local\/box:@\/tmp\/.ICE-unix\/2452,unix\/box:\/tmp\/.ICE-unix\/2452", "SHELL": "\/bin\/bash", "SHLVL": "1", "SSH_AGENT_LAUNCHER": "gnome-keyring", "SSH_AUTH_SOCK": "\/run\/user\/1000\/keyring\/ssh", "SSL_CERT_DIR": "\/usr\/lib\/ssl\/certs", "SSL_CERT_FILE": "\/usr\/lib\/ssl\/certs\/ca-certificates.crt", "SYSTEMD_EXEC_PID": "2470", "TERM": "xterm-256color", "TERM_PROGRAM": "WezTerm", "TERM_PROGRAM_VERSION": "20240127-113634-bbcac864", "THREAD_SUBPROCS": "1", "USER": "user", "USERNAME": "user", "WAYLAND_DISPLAY": "wayland-0", "WEZTERM_CONFIG_DIR": "\/home\/user\/.config\/wezterm", "WEZTERM_CONFIG_FILE": "\/home\/user\/.config\/wezterm\/wezterm.lua", "WEZTERM_EXECUTABLE": "\/usr\/bin\/wezterm-gui", "WEZTERM_EXECUTABLE_DIR": "\/usr\/bin", "WEZTERM_PANE": "41", "WEZTERM_UNIX_SOCKET": "\/run\/user\/1000\/wezterm\/gui-sock-196859", "XAUTHORITY": "\/run\/user\/1000\/.mutter-Xwaylandauth.T986H2", "XDG_CONFIG_DIRS": "\/etc\/xdg\/xdg-ubuntu:\/etc\/xdg", "XDG_CURRENT_DESKTOP": "ubuntu:GNOME", "XDG_DATA_DIRS": "\/usr\/share\/ubuntu:\/usr\/local\/share\/:\/usr\/share\/:\/var\/lib\/snapd\/desktop", "XDG_MENU_PREFIX": "gnome-", "XDG_RUNTIME_DIR": "\/run\/user\/1000", "XDG_SESSION_CLASS": "user", "XDG_SESSION_DESKTOP": "ubuntu", "XDG_SESSION_TYPE": "wayland", "XMODIFIERS": "@im=ibus", "XONSHRC": "\/etc\/xonsh\/xonshrc:\/home\/user\/.config\/xonsh\/rc.xsh:\/home\/user\/.xonshrc", "XONSHRC_DIR": "\/etc\/xonsh\/rc.d:\/home\/user\/.config\/xonsh\/rc.d", "XONSH_CAPTURE_ALWAYS": "", "XONSH_CONFIG_DIR": "\/home\/user\/.config\/xonsh", "XONSH_DATA_DIR": "\/home\/user\/.local\/share\/xonsh", "XONSH_INTERACTIVE": "1", "XONSH_LOGIN": "1", "XONSH_VERSION": "0.14.2"} -, "locked": false, "sessionid": "82eafbf5-9f43-489a-80d2-61c7dc6ef542", "ts": [1707241286.9361255, 1707241292.3081477] -} - -} diff --git a/atuin-client/tests/data/xonsh/xonsh-de16af90-9148-4461-8df3-5b5659c6420d.json b/atuin-client/tests/data/xonsh/xonsh-de16af90-9148-4461-8df3-5b5659c6420d.json deleted file mode 100644 index 72694f04..00000000 --- a/atuin-client/tests/data/xonsh/xonsh-de16af90-9148-4461-8df3-5b5659c6420d.json +++ /dev/null @@ -1,12 +0,0 @@ -{"locs": [ 69, 3372, 3452, 3936], - "index": {"offsets":{"__total__":0,"cmds":[{"__total__":10,"cwd":18,"inp":64,"rtn":94,"ts":[104,124,103]},{"__total__":148,"cwd":156,"inp":202,"rtn":220,"ts":[230,250,229]},9],"env":{"ATUIN_SESSION":300,"BASH_COMPLETIONS":356,"COLORTERM":419,"DBUS_SESSION_BUS_ADDRESS":460,"DESKTOP_SESSION":515,"DISPLAY":536,"GDMSESSION":556,"GIO_LAUNCHED_DESKTOP_FILE":595,"GIO_LAUNCHED_DESKTOP_FILE_PID":690,"GJS_DEBUG_OUTPUT":720,"GJS_DEBUG_TOPICS":750,"GNOME_DESKTOP_SESSION_ID":797,"GNOME_SETUP_DISPLAY":842,"GNOME_SHELL_SESSION_MODE":876,"GTK_MODULES":901,"HOME":928,"IM_CONFIG_PHASE":962,"INVOCATION_ID":984,"JOURNAL_STREAM":1038,"LANG":1057,"LOGNAME":1083,"MANAGERPID":1104,"MOZ_ENABLE_WAYLAND":1134,"PATH":1147,"PWD":1722,"PYENV_DIR":1774,"PYENV_HOOK_PATH":1832,"PYENV_ROOT":2006,"PYENV_SHELL":2044,"PYENV_VERSION":2069,"QT_ACCESSIBILITY":2099,"QT_IM_MODULE":2120,"SESSION_MANAGER":2147,"SHELL":2237,"SHLVL":2261,"SSH_AGENT_LAUNCHER":2288,"SSH_AUTH_SOCK":2322,"SSL_CERT_DIR":2373,"SSL_CERT_FILE":2416,"SYSTEMD_EXEC_PID":2483,"TERM":2499,"TERM_PROGRAM":2533,"TERM_PROGRAM_VERSION":2568,"THREAD_SUBPROCS":2615,"USER":2628,"USERNAME":2647,"WAYLAND_DISPLAY":2673,"WEZTERM_CONFIG_DIR":2708,"WEZTERM_CONFIG_FILE":2764,"WEZTERM_EXECUTABLE":2832,"WEZTERM_EXECUTABLE_DIR":2885,"WEZTERM_PANE":2915,"WEZTERM_UNIX_SOCKET":2944,"XAUTHORITY":3005,"XDG_CONFIG_DIRS":3074,"XDG_CURRENT_DESKTOP":3134,"XDG_DATA_DIRS":3167,"XDG_MENU_PREFIX":3274,"XDG_RUNTIME_DIR":3303,"XDG_SESSION_CLASS":3345,"XDG_SESSION_DESKTOP":3376,"XDG_SESSION_TYPE":3406,"XMODIFIERS":3431,"XONSHRC":3454,"XONSHRC_DIR":3552,"XONSH_CAPTURE_ALWAYS":3632,"XONSH_CONFIG_DIR":3656,"XONSH_DATA_DIR":3705,"XONSH_INTERACTIVE":3763,"XONSH_LOGIN":3783,"XONSH_VERSION":3805,"__total__":282},"locked":3827,"sessionid":3847,"ts":[3894,3914,3893]},"sizes":{"__total__":3936,"cmds":[{"__total__":136,"cwd":37,"inp":21,"rtn":1,"ts":[18,18,41]},{"__total__":123,"cwd":37,"inp":9,"rtn":1,"ts":[18,17,40]},264],"env":{"ATUIN_SESSION":34,"BASH_COMPLETIONS":48,"COLORTERM":11,"DBUS_SESSION_BUS_ADDRESS":34,"DESKTOP_SESSION":8,"DISPLAY":4,"GDMSESSION":8,"GIO_LAUNCHED_DESKTOP_FILE":60,"GIO_LAUNCHED_DESKTOP_FILE_PID":8,"GJS_DEBUG_OUTPUT":8,"GJS_DEBUG_TOPICS":17,"GNOME_DESKTOP_SESSION_ID":20,"GNOME_SETUP_DISPLAY":4,"GNOME_SHELL_SESSION_MODE":8,"GTK_MODULES":17,"HOME":13,"IM_CONFIG_PHASE":3,"INVOCATION_ID":34,"JOURNAL_STREAM":9,"LANG":13,"LOGNAME":5,"MANAGERPID":6,"MOZ_ENABLE_WAYLAND":3,"PATH":566,"PWD":37,"PYENV_DIR":37,"PYENV_HOOK_PATH":158,"PYENV_ROOT":21,"PYENV_SHELL":6,"PYENV_VERSION":8,"QT_ACCESSIBILITY":3,"QT_IM_MODULE":6,"SESSION_MANAGER":79,"SHELL":13,"SHLVL":3,"SSH_AGENT_LAUNCHER":15,"SSH_AUTH_SOCK":33,"SSL_CERT_DIR":24,"SSL_CERT_FILE":45,"SYSTEMD_EXEC_PID":6,"TERM":16,"TERM_PROGRAM":9,"TERM_PROGRAM_VERSION":26,"THREAD_SUBPROCS":3,"USER":5,"USERNAME":5,"WAYLAND_DISPLAY":11,"WEZTERM_CONFIG_DIR":31,"WEZTERM_CONFIG_FILE":44,"WEZTERM_EXECUTABLE":25,"WEZTERM_EXECUTABLE_DIR":12,"WEZTERM_PANE":4,"WEZTERM_UNIX_SOCKET":45,"XAUTHORITY":48,"XDG_CONFIG_DIRS":35,"XDG_CURRENT_DESKTOP":14,"XDG_DATA_DIRS":86,"XDG_MENU_PREFIX":8,"XDG_RUNTIME_DIR":19,"XDG_SESSION_CLASS":6,"XDG_SESSION_DESKTOP":8,"XDG_SESSION_TYPE":9,"XMODIFIERS":10,"XONSHRC":81,"XONSHRC_DIR":54,"XONSH_CAPTURE_ALWAYS":2,"XONSH_CONFIG_DIR":29,"XONSH_DATA_DIR":35,"XONSH_INTERACTIVE":3,"XONSH_LOGIN":3,"XONSH_VERSION":8,"__total__":3533},"locked":5,"sessionid":38,"ts":[18,18,41]}}, - "data": {"cmds": [{"cwd": "\/home\/user\/Documents\/code\/atuin", "inp": "echo hello world!\n", "rtn": 0, "ts": [1707193079.4782722, 1707193079.4829233] -} -, {"cwd": "\/home\/user\/Documents\/code\/atuin", "inp": "ls -l\n", "rtn": 0, "ts": [1707193081.7063284, 1707193081.727617] -} -] -, "env": {"ATUIN_SESSION": "018d7ca2e953742e9826012f30115040", "BASH_COMPLETIONS": "\/usr\/share\/bash-completion\/bash_completion", "COLORTERM": "truecolor", "DBUS_SESSION_BUS_ADDRESS": "unix:path=\/run\/user\/1000\/bus", "DESKTOP_SESSION": "ubuntu", "DISPLAY": ":0", "GDMSESSION": "ubuntu", "GIO_LAUNCHED_DESKTOP_FILE": "\/usr\/share\/applications\/org.wezfurlong.wezterm.desktop", "GIO_LAUNCHED_DESKTOP_FILE_PID": "196859", "GJS_DEBUG_OUTPUT": "stderr", "GJS_DEBUG_TOPICS": "JS ERROR;JS LOG", "GNOME_DESKTOP_SESSION_ID": "this-is-deprecated", "GNOME_SETUP_DISPLAY": ":1", "GNOME_SHELL_SESSION_MODE": "ubuntu", "GTK_MODULES": "gail:atk-bridge", "HOME": "\/home\/user", "IM_CONFIG_PHASE": "1", "INVOCATION_ID": "4f121e7ad56c41a6b84aa3cbe1ad61fa", "JOURNAL_STREAM": "8:37187", "LANG": "en_US.UTF-8", "LOGNAME": "user", "MANAGERPID": "2118", "MOZ_ENABLE_WAYLAND": "1", "PATH": "\/home\/user\/.pyenv\/versions\/3.12.0\/bin:\/home\/user\/.pyenv\/libexec:\/home\/user\/.pyenv\/plugins\/python-build\/bin:\/home\/user\/.pyenv\/plugins\/pyenv-virtualenv\/bin:\/home\/user\/.pyenv\/plugins\/pyenv-update\/bin:\/home\/user\/.pyenv\/plugins\/pyenv-doctor\/bin:\/home\/user\/.cargo\/bin:\/home\/user\/.pyenv\/shims:\/home\/user\/.pyenv\/bin:\/home\/user\/bin:\/home\/user\/bin:\/usr\/local\/sbin:\/usr\/local\/bin:\/usr\/sbin:\/usr\/bin:\/sbin:\/bin:\/usr\/games:\/usr\/local\/games:\/snap\/bin:\/snap\/bin:\/home\/user\/.local\/share\/JetBrains\/Toolbox\/scripts", "PWD": "\/home\/user\/Documents\/code\/atuin", "PYENV_DIR": "\/home\/user\/Documents\/code\/atuin", "PYENV_HOOK_PATH": "\/home\/user\/.pyenv\/pyenv.d:\/usr\/local\/etc\/pyenv.d:\/etc\/pyenv.d:\/usr\/lib\/pyenv\/hooks:\/home\/user\/.pyenv\/plugins\/pyenv-virtualenv\/etc\/pyenv.d", "PYENV_ROOT": "\/home\/user\/.pyenv", "PYENV_SHELL": "bash", "PYENV_VERSION": "3.12.0", "QT_ACCESSIBILITY": "1", "QT_IM_MODULE": "ibus", "SESSION_MANAGER": "local\/box:@\/tmp\/.ICE-unix\/2452,unix\/box:\/tmp\/.ICE-unix\/2452", "SHELL": "\/bin\/bash", "SHLVL": "1", "SSH_AGENT_LAUNCHER": "gnome-keyring", "SSH_AUTH_SOCK": "\/run\/user\/1000\/keyring\/ssh", "SSL_CERT_DIR": "\/usr\/lib\/ssl\/certs", "SSL_CERT_FILE": "\/usr\/lib\/ssl\/certs\/ca-certificates.crt", "SYSTEMD_EXEC_PID": "2470", "TERM": "xterm-256color", "TERM_PROGRAM": "WezTerm", "TERM_PROGRAM_VERSION": "20240127-113634-bbcac864", "THREAD_SUBPROCS": "1", "USER": "user", "USERNAME": "user", "WAYLAND_DISPLAY": "wayland-0", "WEZTERM_CONFIG_DIR": "\/home\/user\/.config\/wezterm", "WEZTERM_CONFIG_FILE": "\/home\/user\/.config\/wezterm\/wezterm.lua", "WEZTERM_EXECUTABLE": "\/usr\/bin\/wezterm-gui", "WEZTERM_EXECUTABLE_DIR": "\/usr\/bin", "WEZTERM_PANE": "38", "WEZTERM_UNIX_SOCKET": "\/run\/user\/1000\/wezterm\/gui-sock-196859", "XAUTHORITY": "\/run\/user\/1000\/.mutter-Xwaylandauth.T986H2", "XDG_CONFIG_DIRS": "\/etc\/xdg\/xdg-ubuntu:\/etc\/xdg", "XDG_CURRENT_DESKTOP": "ubuntu:GNOME", "XDG_DATA_DIRS": "\/usr\/share\/ubuntu:\/usr\/local\/share\/:\/usr\/share\/:\/var\/lib\/snapd\/desktop", "XDG_MENU_PREFIX": "gnome-", "XDG_RUNTIME_DIR": "\/run\/user\/1000", "XDG_SESSION_CLASS": "user", "XDG_SESSION_DESKTOP": "ubuntu", "XDG_SESSION_TYPE": "wayland", "XMODIFIERS": "@im=ibus", "XONSHRC": "\/etc\/xonsh\/xonshrc:\/home\/user\/.config\/xonsh\/rc.xsh:\/home\/user\/.xonshrc", "XONSHRC_DIR": "\/etc\/xonsh\/rc.d:\/home\/user\/.config\/xonsh\/rc.d", "XONSH_CAPTURE_ALWAYS": "", "XONSH_CONFIG_DIR": "\/home\/user\/.config\/xonsh", "XONSH_DATA_DIR": "\/home\/user\/.local\/share\/xonsh", "XONSH_INTERACTIVE": "1", "XONSH_LOGIN": "1", "XONSH_VERSION": "0.14.2"} -, "locked": false, "sessionid": "de16af90-9148-4461-8df3-5b5659c6420d", "ts": [1707193067.8615997, 1707193089.2513068] -} - -} -- cgit v1.3.1