From 4077c33adfdacaf0ed68657a1955a7b69a78d373 Mon Sep 17 00:00:00 2001 From: Vlad Stepanov <8uk.8ak@gmail.com> Date: Thu, 15 Jun 2023 14:29:40 +0400 Subject: [PATCH] Builder interface for History objects (#933) * [feature] store env variables in History records WIP: remove `HistoryWithoutDelete`, add some docstrings, tests * Create History objects through builders. Assure in compile-time that all required fields are set for the given construction scenario * (from #882) split Cmd::run into subfns * Update `History` doc * remove rmp-serde from history * update warning --------- Co-authored-by: Conrad Ludgate --- Cargo.lock | 2 + atuin-client/Cargo.toml | 2 + atuin-client/src/database.rs | 47 ++--- atuin-client/src/encryption.rs | 261 +++++++++++++++++++++----- atuin-client/src/history.rs | 152 +++++++++++++-- atuin-client/src/history/builder.rs | 100 ++++++++++ atuin-client/src/import/bash.rs | 14 +- atuin-client/src/import/fish.rs | 28 +-- atuin-client/src/import/nu.rs | 14 +- atuin-client/src/import/nu_histdb.rs | 23 ++- atuin-client/src/import/resh.rs | 23 ++- atuin-client/src/import/zsh.rs | 35 ++-- atuin-client/src/import/zsh_histdb.rs | 36 ++-- atuin-client/src/message.rs | 5 - atuin-server-database/src/models.rs | 6 + atuin/src/command/client/history.rs | 215 +++++++++++---------- 16 files changed, 669 insertions(+), 294 deletions(-) create mode 100644 atuin-client/src/history/builder.rs delete mode 100644 atuin-client/src/message.rs diff --git a/Cargo.lock b/Cargo.lock index a8ee317..cd2556e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -154,6 +154,7 @@ dependencies = [ "rand", "regex", "reqwest", + "rmp", "rmp-serde", "semver", "serde", @@ -164,6 +165,7 @@ dependencies = [ "sql-builder", "sqlx", "tokio", + "typed-builder", "urlencoding", "uuid", "whoami", diff --git a/atuin-client/Cargo.toml b/atuin-client/Cargo.toml index 7b85bf7..e00dc91 100644 --- a/atuin-client/Cargo.toml +++ b/atuin-client/Cargo.toml @@ -50,7 +50,9 @@ fs-err = { workspace = true } sql-builder = "3" lazy_static = "1" memchr = "2.5" +rmp = { version = "0.8.11" } rmp-serde = { version = "1.1.1" } +typed-builder = "0.14.0" # sync urlencoding = { version = "2.1.0", optional = true } diff --git a/atuin-client/src/database.rs b/atuin-client/src/database.rs index a2d8c53..b7b4440 100644 --- a/atuin-client/src/database.rs +++ b/atuin-client/src/database.rs @@ -168,17 +168,18 @@ impl Sqlite { fn query_history(row: SqliteRow) -> History { let deleted_at: Option = row.get("deleted_at"); - History { - id: row.get("id"), - timestamp: Utc.timestamp_nanos(row.get("timestamp")), - 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.map(|t| Utc.timestamp_nanos(t)), - } + History::from_db() + .id(row.get("id")) + .timestamp(Utc.timestamp_nanos(row.get("timestamp"))) + .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.map(|t| Utc.timestamp_nanos(t))) + .build() + .into() } } @@ -594,17 +595,19 @@ mod test { } async fn new_history_item(db: &mut impl Database, cmd: &str) -> Result<()> { - let history = History::new( - chrono::Utc::now(), - cmd.to_string(), - "/home/ellie".to_string(), - 0, - 1, - Some("beep boop".to_string()), - Some("booop".to_string()), - None, - ); - db.save(&history).await + let mut captured: History = History::capture() + .timestamp(chrono::Utc::now()) + .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")] diff --git a/atuin-client/src/encryption.rs b/atuin-client/src/encryption.rs index 7ed640a..a7aec0e 100644 --- a/atuin-client/src/encryption.rs +++ b/atuin-client/src/encryption.rs @@ -11,8 +11,10 @@ use std::{io::prelude::*, path::PathBuf}; use base64::prelude::{Engine, BASE64_STANDARD}; -use eyre::{eyre, Context, Result}; +use chrono::{DateTime, Utc}; +use eyre::{bail, eyre, Context, Result}; use fs_err as fs; +use rmp::{decode::Bytes, Marker}; use serde::{Deserialize, Serialize}; pub use xsalsa20poly1305::Key; use xsalsa20poly1305::{ @@ -20,10 +22,7 @@ use xsalsa20poly1305::{ AeadInPlace, KeyInit, XSalsa20Poly1305, }; -use crate::{ - history::{History, HistoryWithoutDelete}, - settings::Settings, -}; +use crate::{history::History, settings::Settings}; #[derive(Debug, Serialize, Deserialize)] pub struct EncryptedHistory { @@ -75,7 +74,9 @@ pub fn load_encoded_key(settings: &Settings) -> Result { } pub fn encode_key(key: &Key) -> Result { - let buf = rmp_serde::to_vec(key.as_slice()).wrap_err("could not encode key to message pack")?; + let mut buf = vec![]; + rmp::encode::write_bin(&mut buf, key.as_slice()) + .wrap_err("could not encode key to message pack")?; let buf = BASE64_STANDARD.encode(buf); Ok(buf) @@ -86,23 +87,23 @@ pub fn decode_key(key: String) -> Result { .decode(key.trim_end()) .wrap_err("encryption key is not a valid base64 encoding")?; - let mbuf: Result<[u8; 32]> = - rmp_serde::from_slice(&buf).wrap_err("encryption key is not a valid message pack encoding"); - - match mbuf { - Ok(b) => Ok(*Key::from_slice(&b)), - Err(_) => { - let buf: &[u8] = rmp_serde::from_slice(&buf) - .wrap_err("encryption key is not a valid message pack encoding")?; - - Ok(*Key::from_slice(buf)) + // old code wrote the key as a fixed length array of 32 bytes + // new code writes the key with a length prefix + if buf.len() == 32 { + Ok(*Key::from_slice(&buf)) + } else { + let mut bytes = Bytes::new(&buf); + let key_len = rmp::decode::read_bin_len(&mut bytes).map_err(error_report)?; + if key_len != 32 || bytes.remaining_slice().len() != key_len as usize { + bail!("encryption key is not the correct size") } + Ok(*Key::from_slice(bytes.remaining_slice())) } } pub fn encrypt(history: &History, key: &Key) -> Result { // serialize with msgpack - let mut buf = rmp_serde::to_vec(history)?; + let mut buf = encode(history)?; let nonce = XSalsa20Poly1305::generate_nonce(&mut OsRng); XSalsa20Poly1305::new(key) @@ -125,50 +126,138 @@ pub fn decrypt(mut encrypted_history: EncryptedHistory, key: &Key) -> Result 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)?; + encode::write_str( + &mut output, + &dbg!(h + .timestamp + .to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true)), + )?; + 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, + &d.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true), + )?, + 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(), + timestamp: DateTime::parse_from_rfc3339(timestamp)?.with_timezone(&Utc), + duration, + exit, + command: command.to_owned(), + cwd: cwd.to_owned(), + session: session.to_owned(), + hostname: hostname.to_owned(), + deleted_at: deleted_at + .map(DateTime::parse_from_rfc3339) + .transpose()? + .map(|dt| dt.with_timezone(&Utc)), + }) +} + +fn error_report(err: E) -> eyre::Report { + eyre!("{err:?}") +} + #[cfg(test)] mod test { use xsalsa20poly1305::{aead::OsRng, KeyInit, XSalsa20Poly1305}; use crate::history::History; - use super::{decrypt, encrypt}; + 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::new( - chrono::Utc::now(), - "ls".to_string(), - "/home/ellie".to_string(), - 0, - 1, - Some("beep boop".to_string()), - Some("booop".to_string()), - None, - ); + let history = History::from_db() + .id("1".into()) + .timestamp(chrono::Utc::now()) + .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(); @@ -186,4 +275,86 @@ mod test { // 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(), + timestamp: "2023-05-28T18:35:40.633872Z".parse().unwrap(), + 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(), + timestamp: "2023-05-28T18:35:40.633872Z".parse().unwrap(), + 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("2023-05-28T18:35:40.633872Z".parse().unwrap()), + }; + + 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(), + timestamp: "2023-05-28T18:35:40.633872Z".parse().unwrap(), + 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); + } } diff --git a/atuin-client/src/history.rs b/atuin-client/src/history.rs index 05bfbf7..441960c 100644 --- a/atuin-client/src/history.rs +++ b/atuin-client/src/history.rs @@ -1,42 +1,51 @@ use std::env; use chrono::Utc; -use serde::{Deserialize, Serialize}; use atuin_common::utils::uuid_v7; -// Any new fields MUST be Optional<>! -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, sqlx::FromRow)] -pub struct History { - pub id: String, - pub timestamp: chrono::DateTime, - pub duration: i64, - pub exit: i64, - pub command: String, - pub cwd: String, - pub session: String, - pub hostname: String, - pub deleted_at: Option>, -} +mod builder; -// Forgive me, for I have sinned -// I need to replace rmp with something that is more backwards-compatible. -// Protobuf, or maybe just json -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, sqlx::FromRow)] -pub struct HistoryWithoutDelete { +/// 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: String, + /// When the command was run. pub timestamp: chrono::DateTime, + /// 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>, } impl History { #[allow(clippy::too_many_arguments)] - pub fn new( + fn new( timestamp: chrono::DateTime, command: String, cwd: String, @@ -70,6 +79,109 @@ impl History { } } + /// 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(chrono::Utc::now()) + /// .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(chrono::Utc::now()) + /// .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(chrono::Utc::now()) + /// .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(chrono::Utc::now()) + /// .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(chrono::Utc::now()) + /// .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 } diff --git a/atuin-client/src/history/builder.rs b/atuin-client/src/history/builder.rs new file mode 100644 index 0000000..dc22b60 --- /dev/null +++ b/atuin-client/src/history/builder.rs @@ -0,0 +1,100 @@ +use chrono::Utc; +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: chrono::DateTime, + #[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: chrono::DateTime, + #[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: chrono::DateTime, + 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, + 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/import/bash.rs b/atuin-client/src/import/bash.rs index 9901c1f..25ede05 100644 --- a/atuin-client/src/import/bash.rs +++ b/atuin-client/src/import/bash.rs @@ -80,17 +80,9 @@ impl Importer for Bash { next_timestamp = t; } LineType::Command(c) => { - let entry = History::new( - next_timestamp, - c.into(), - "unknown".into(), - -1, - -1, - None, - None, - None, - ); - h.push(entry).await?; + let imported = History::import().timestamp(next_timestamp).command(c); + + h.push(imported.build().into()).await?; next_timestamp += timestamp_increment; } } diff --git a/atuin-client/src/import/fish.rs b/atuin-client/src/import/fish.rs index e54ca73..90ecabc 100644 --- a/atuin-client/src/import/fish.rs +++ b/atuin-client/src/import/fish.rs @@ -73,19 +73,9 @@ impl Importer for Fish { // 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(History::new( - time, - cmd, - "unknown".into(), - -1, - -1, - None, - None, - None, - )) - .await?; + loader.push(entry.build().into()).await?; } // using raw strings to avoid needing escaping. @@ -109,19 +99,9 @@ impl Importer for Fish { // 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(History::new( - time, - cmd, - "unknown".into(), - -1, - -1, - None, - None, - None, - )) - .await?; + loader.push(entry.build().into()).await?; } Ok(()) diff --git a/atuin-client/src/import/nu.rs b/atuin-client/src/import/nu.rs index 0f10760..4660032 100644 --- a/atuin-client/src/import/nu.rs +++ b/atuin-client/src/import/nu.rs @@ -58,17 +58,9 @@ impl Importer for Nu { let offset = chrono::Duration::nanoseconds(counter); counter += 1; - h.push(History::new( - now - offset, // preserve ordering - cmd, - String::from("unknown"), - -1, - -1, - None, - None, - None, - )) - .await?; + 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 index 0fb5192..34568d8 100644 --- a/atuin-client/src/import/nu_histdb.rs +++ b/atuin-client/src/import/nu_histdb.rs @@ -30,16 +30,19 @@ 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; - History::new( - DateTime::from_utc(NaiveDateTime::from_timestamp(ts_secs, ts_ns as u32), Utc), - String::from_utf8(histdb_item.command_line).unwrap(), - String::from_utf8(histdb_item.cwd).unwrap(), - histdb_item.exit_status, - histdb_item.duration_ms, - Some(format!("{:x}", histdb_item.session_id)), - Some(String::from_utf8(histdb_item.hostname).unwrap()), - None, - ) + let imported = History::import() + .timestamp(DateTime::from_utc( + NaiveDateTime::from_timestamp(ts_secs, ts_ns as u32), + Utc, + )) + .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() } } diff --git a/atuin-client/src/import/resh.rs b/atuin-client/src/import/resh.rs index 6fa27b5..3c5b799 100644 --- a/atuin-client/src/import/resh.rs +++ b/atuin-client/src/import/resh.rs @@ -122,18 +122,17 @@ impl Importer for Resh { difference.num_nanoseconds().unwrap_or(0) }; - h.push(History { - id: uuid_v7().as_simple().to_string(), - timestamp, - duration, - exit: entry.exit_code, - command: entry.cmd_line, - cwd: entry.pwd, - session: uuid_v7().as_simple().to_string(), - hostname: entry.host, - deleted_at: None, - }) - .await?; + 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/zsh.rs b/atuin-client/src/import/zsh.rs index 19917da..e98819e 100644 --- a/atuin-client/src/import/zsh.rs +++ b/atuin-client/src/import/zsh.rs @@ -82,17 +82,12 @@ impl Importer for Zsh { let offset = chrono::Duration::seconds(counter); counter += 1; - h.push(History::new( - now - offset, // preserve ordering - command.trim_end().to_string(), - String::from("unknown"), - -1, - -1, - None, - None, - None, - )) - .await?; + let imported = History::import() + // preserve ordering + .timestamp(now - offset) + .command(command.trim_end().to_string()); + + h.push(imported.build().into()).await?; } } } @@ -113,19 +108,15 @@ fn parse_extended(line: &str, counter: i64) -> History { let time = Utc.timestamp(time, 0); let time = time + offset; + // 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); - // use nanos, because why the hell not? we won't display them. - History::new( - time, - command.trim_end().to_string(), - String::from("unknown"), - 0, // assume 0, we have no way of knowing :( - duration, - None, - None, - None, - ) + let imported = History::import() + .timestamp(time) + .command(command.trim_end().to_string()) + .duration(duration); + + imported.build().into() } #[cfg(test)] diff --git a/atuin-client/src/import/zsh_histdb.rs b/atuin-client/src/import/zsh_histdb.rs index 2f9a192..78a7176 100644 --- a/atuin-client/src/import/zsh_histdb.rs +++ b/atuin-client/src/import/zsh_histdb.rs @@ -61,27 +61,29 @@ pub struct HistDbEntry { impl From for History { fn from(histdb_item: HistDbEntry) -> Self { - History::new( - DateTime::from_utc(histdb_item.start_time, Utc), // must assume UTC? - String::from_utf8(histdb_item.argv) - .unwrap_or_else(|_e| String::from("")) - .trim_end() - .to_string(), - String::from_utf8(histdb_item.dir) - .unwrap_or_else(|_e| String::from("")) - .trim_end() - .to_string(), - 0, // assume 0, we have no way of knowing :( - histdb_item.duration, - None, - Some( + let imported = History::import() + .timestamp(DateTime::from_utc(histdb_item.start_time, Utc)) + .command( + String::from_utf8(histdb_item.argv) + .unwrap_or_else(|_e| String::from("")) + .trim_end() + .to_string(), + ) + .cwd( + String::from_utf8(histdb_item.dir) + .unwrap_or_else(|_e| String::from("")) + .trim_end() + .to_string(), + ) + .duration(histdb_item.duration) + .hostname( String::from_utf8(histdb_item.host) .unwrap_or_else(|_e| String::from("")) .trim_end() .to_string(), - ), - None, - ) + ); + + imported.build().into() } } diff --git a/atuin-client/src/message.rs b/atuin-client/src/message.rs deleted file mode 100644 index 1c34ee4..0000000 --- a/atuin-client/src/message.rs +++ /dev/null @@ -1,5 +0,0 @@ - -pub struct Message { - pub id: Uuid, - pub type: String, -} diff --git a/atuin-server-database/src/models.rs b/atuin-server-database/src/models.rs index a95ceba..7183b1e 100644 --- a/atuin-server-database/src/models.rs +++ b/atuin-server-database/src/models.rs @@ -7,6 +7,9 @@ pub struct History { pub hostname: String, pub timestamp: NaiveDateTime, + /// All the data we have about this command, encrypted. + /// + /// Currently this is an encrypted msgpack object, but this may change in the future. pub data: String, pub created_at: NaiveDateTime, @@ -18,6 +21,9 @@ pub struct NewHistory { pub hostname: String, pub timestamp: chrono::NaiveDateTime, + /// All the data we have about this command, encrypted. + /// + /// Currently this is an encrypted msgpack object, but this may change in the future. pub data: String, } diff --git a/atuin/src/command/client/history.rs b/atuin/src/command/client/history.rs index ea68001..f8154e5 100644 --- a/atuin/src/command/client/history.rs +++ b/atuin/src/command/client/history.rs @@ -184,67 +184,126 @@ fn parse_fmt(format: &str) -> ParsedFmt { } impl Cmd { - pub async fn run(&self, settings: &Settings, db: &mut impl Database) -> Result<()> { + async fn handle_start( + db: &mut impl Database, + settings: &Settings, + command: &[String], + ) -> Result<()> { + let command = command.join(" "); + + if command.starts_with(' ') || settings.history_filter.is_match(&command) { + return Ok(()); + } + + // It's better for atuin to silently fail here and attempt to + // store whatever is ran, than to throw an error to the terminal + let cwd = utils::get_current_dir(); + if !cwd.is_empty() && settings.cwd_filter.is_match(&cwd) { + return Ok(()); + } + + let h: History = History::capture() + .timestamp(chrono::Utc::now()) + .command(command) + .cwd(cwd) + .build() + .into(); + + // print the ID + // we use this as the key for calling end + println!("{}", h.id); + db.save(&h).await?; + Ok(()) + } + + async fn handle_end( + db: &mut impl Database, + settings: &Settings, + id: &str, + exit: i64, + ) -> Result<()> { + if id.trim() == "" { + return Ok(()); + } + + let mut h = db.load(id).await?; + + if h.duration > 0 { + debug!("cannot end history - already has duration"); + + // returning OK as this can occur if someone Ctrl-c a prompt + return Ok(()); + } + + h.exit = exit; + h.duration = chrono::Utc::now().timestamp_nanos() - h.timestamp.timestamp_nanos(); + + db.update(&h).await?; + + if settings.should_sync()? { + #[cfg(feature = "sync")] + { + debug!("running periodic background sync"); + sync::sync(settings, false, db).await?; + } + #[cfg(not(feature = "sync"))] + debug!("not compiled with sync support"); + } else { + debug!("sync disabled! not syncing"); + } + + Ok(()) + } + + async fn handle_list( + db: &mut impl Database, + settings: &Settings, + context: atuin_client::database::Context, + session: bool, + cwd: bool, + mode: ListMode, + format: Option, + ) -> Result<()> { + let session = if session { + Some(env::var("ATUIN_SESSION")?) + } else { + None + }; + let cwd = if cwd { + Some(utils::get_current_dir()) + } else { + None + }; + + let history = match (session, cwd) { + (None, None) => db.list(settings.filter_mode, &context, None, false).await?, + (None, Some(cwd)) => { + let query = format!("select * from history where cwd = '{cwd}';"); + db.query_history(&query).await? + } + (Some(session), None) => { + let query = format!("select * from history where session = '{session}';"); + db.query_history(&query).await? + } + (Some(session), Some(cwd)) => { + let query = format!( + "select * from history where cwd = '{cwd}' and session = '{session}';", + ); + db.query_history(&query).await? + } + }; + + print_list(&history, mode, format.as_deref()); + + Ok(()) + } + + pub async fn run(self, settings: &Settings, db: &mut impl Database) -> Result<()> { let context = current_context(); match self { - Self::Start { command: words } => { - let command = words.join(" "); - - if command.starts_with(' ') || settings.history_filter.is_match(&command) { - return Ok(()); - } - - // It's better for atuin to silently fail here and attempt to - // store whatever is ran, than to throw an error to the terminal - let cwd = utils::get_current_dir(); - if !cwd.is_empty() && settings.cwd_filter.is_match(&cwd) { - return Ok(()); - } - - let h = History::new(chrono::Utc::now(), command, cwd, -1, -1, None, None, None); - - // print the ID - // we use this as the key for calling end - println!("{}", h.id); - db.save(&h).await?; - Ok(()) - } - - Self::End { id, exit } => { - if id.trim() == "" { - return Ok(()); - } - - let mut h = db.load(id).await?; - - if h.duration > 0 { - debug!("cannot end history - already has duration"); - - // returning OK as this can occur if someone Ctrl-c a prompt - return Ok(()); - } - - h.exit = *exit; - h.duration = chrono::Utc::now().timestamp_nanos() - h.timestamp.timestamp_nanos(); - - db.update(&h).await?; - - if settings.should_sync()? { - #[cfg(feature = "sync")] - { - debug!("running periodic background sync"); - sync::sync(settings, false, db).await?; - } - #[cfg(not(feature = "sync"))] - debug!("not compiled with sync support"); - } else { - debug!("sync disabled! not syncing"); - } - - Ok(()) - } - + Self::Start { command } => Self::handle_start(db, settings, &command).await, + Self::End { id, exit } => Self::handle_end(db, settings, &id, exit).await, Self::List { session, cwd, @@ -252,42 +311,8 @@ impl Cmd { cmd_only, format, } => { - let session = if *session { - Some(env::var("ATUIN_SESSION")?) - } else { - None - }; - let cwd = if *cwd { - Some(utils::get_current_dir()) - } else { - None - }; - - let history = match (session, cwd) { - (None, None) => db.list(settings.filter_mode, &context, None, false).await?, - (None, Some(cwd)) => { - let query = format!("select * from history where cwd = '{cwd}';"); - db.query_history(&query).await? - } - (Some(session), None) => { - let query = format!("select * from history where session = '{session}';"); - db.query_history(&query).await? - } - (Some(session), Some(cwd)) => { - let query = format!( - "select * from history where cwd = '{cwd}' and session = '{session}';", - ); - db.query_history(&query).await? - } - }; - - print_list( - &history, - ListMode::from_flags(*human, *cmd_only), - format.as_deref(), - ); - - Ok(()) + let mode = ListMode::from_flags(human, cmd_only); + Self::handle_list(db, settings, context, session, cwd, mode, format).await } Self::Last { @@ -298,7 +323,7 @@ impl Cmd { let last = db.last().await?; print_list( &[last], - ListMode::from_flags(*human, *cmd_only), + ListMode::from_flags(human, cmd_only), format.as_deref(), );