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 <conrad.ludgate@truelayer.com>
This commit is contained in:
Vlad Stepanov 2023-06-15 14:29:40 +04:00 committed by GitHub
parent 0c75cfbfda
commit 4077c33adf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 669 additions and 294 deletions

2
Cargo.lock generated
View file

@ -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",

View file

@ -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 }

View file

@ -168,17 +168,18 @@ impl Sqlite {
fn query_history(row: SqliteRow) -> History {
let deleted_at: Option<i64> = 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")]

View file

@ -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<String> {
}
pub fn encode_key(key: &Key) -> Result<String> {
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<Key> {
.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<EncryptedHistory> {
// 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<His
.map_err(|_| eyre!("could not encrypt"))?;
let plaintext = encrypted_history.ciphertext;
let history = rmp_serde::from_slice(&plaintext);
let Ok(history) = history else {
let history: HistoryWithoutDelete = rmp_serde::from_slice(&plaintext)?;
return Ok(History {
id: history.id,
cwd: history.cwd,
exit: history.exit,
command: history.command,
session: history.session,
duration: history.duration,
hostname: history.hostname,
timestamp: history.timestamp,
deleted_at: None,
});
};
let history = decode(&plaintext)?;
Ok(history)
}
fn encode(h: &History) -> Result<Vec<u8>> {
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<History> {
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<E: std::fmt::Debug>(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);
}
}

View file

@ -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<Utc>,
pub duration: i64,
pub exit: i64,
pub command: String,
pub cwd: String,
pub session: String,
pub hostname: String,
pub deleted_at: Option<chrono::DateTime<Utc>>,
}
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<Utc>,
/// 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<chrono::DateTime<Utc>>,
}
impl History {
#[allow(clippy::too_many_arguments)]
pub fn new(
fn new(
timestamp: chrono::DateTime<Utc>,
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
}

View file

@ -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<Utc>,
#[builder(setter(into))]
command: String,
#[builder(default = "unknown".into(), setter(into))]
cwd: String,
#[builder(default = -1)]
exit: i64,
#[builder(default = -1)]
duration: i64,
#[builder(default, setter(strip_option, into))]
session: Option<String>,
#[builder(default, setter(strip_option, into))]
hostname: Option<String>,
}
impl From<HistoryImported> 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<Utc>,
#[builder(setter(into))]
command: String,
#[builder(setter(into))]
cwd: String,
}
impl From<HistoryCaptured> 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<Utc>,
command: String,
cwd: String,
exit: i64,
duration: i64,
session: String,
hostname: String,
deleted_at: Option<chrono::DateTime<Utc>>,
}
impl From<HistoryFromDb> 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,
}
}
}

View file

@ -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;
}
}

View file

@ -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(())

View file

@ -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(())

View file

@ -30,16 +30,19 @@ impl From<HistDbEntry> 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()
}
}

View file

@ -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(())

View file

@ -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::<i64>().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)]

View file

@ -61,27 +61,29 @@ pub struct HistDbEntry {
impl From<HistDbEntry> 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()
}
}

View file

@ -1,5 +0,0 @@
pub struct Message {
pub id: Uuid,
pub type: String,
}

View file

@ -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,
}

View file

@ -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<String>,
) -> 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(),
);