atuin_client/
history.rs

1use core::fmt::Formatter;
2use rmp::decode::ValueReadError;
3use rmp::{decode::Bytes, Marker};
4use std::env;
5use std::fmt::Display;
6
7use atuin_common::record::DecryptedData;
8use atuin_common::utils::uuid_v7;
9
10use eyre::{bail, eyre, Result};
11use regex::RegexSet;
12
13use crate::utils::get_host_user;
14use crate::{secrets::SECRET_PATTERNS, settings::Settings};
15use time::OffsetDateTime;
16
17mod builder;
18pub mod store;
19
20const HISTORY_VERSION: &str = "v0";
21pub const HISTORY_TAG: &str = "history";
22
23#[derive(Clone, Debug, Eq, PartialEq, Hash)]
24pub struct HistoryId(pub String);
25
26impl Display for HistoryId {
27    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
28        write!(f, "{}", self.0)
29    }
30}
31
32impl From<String> for HistoryId {
33    fn from(s: String) -> Self {
34        Self(s)
35    }
36}
37
38/// Client-side history entry.
39///
40/// Client stores data unencrypted, and only encrypts it before sending to the server.
41///
42/// To create a new history entry, use one of the builders:
43/// - [`History::import()`] to import an entry from the shell history file
44/// - [`History::capture()`] to capture an entry via hook
45/// - [`History::from_db()`] to create an instance from the database entry
46//
47// ## Implementation Notes
48//
49// New fields must should be added to `encryption::{encode, decode}` in a backwards
50// compatible way. (eg sensible defaults and updating the nfields parameter)
51#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
52pub struct History {
53    /// A client-generated ID, used to identify the entry when syncing.
54    ///
55    /// Stored as `client_id` in the database.
56    pub id: HistoryId,
57    /// When the command was run.
58    pub timestamp: OffsetDateTime,
59    /// How long the command took to run.
60    pub duration: i64,
61    /// The exit code of the command.
62    pub exit: i64,
63    /// The command that was run.
64    pub command: String,
65    /// The current working directory when the command was run.
66    pub cwd: String,
67    /// The session ID, associated with a terminal session.
68    pub session: String,
69    /// The hostname of the machine the command was run on.
70    pub hostname: String,
71    /// Timestamp, which is set when the entry is deleted, allowing a soft delete.
72    pub deleted_at: Option<OffsetDateTime>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
76pub struct HistoryStats {
77    /// The command that was ran after this one in the session
78    pub next: Option<History>,
79    ///
80    /// The command that was ran before this one in the session
81    pub previous: Option<History>,
82
83    /// How many times has this command been ran?
84    pub total: u64,
85
86    pub average_duration: u64,
87
88    pub exits: Vec<(i64, i64)>,
89
90    pub day_of_week: Vec<(String, i64)>,
91
92    pub duration_over_time: Vec<(String, i64)>,
93}
94
95impl History {
96    #[allow(clippy::too_many_arguments)]
97    fn new(
98        timestamp: OffsetDateTime,
99        command: String,
100        cwd: String,
101        exit: i64,
102        duration: i64,
103        session: Option<String>,
104        hostname: Option<String>,
105        deleted_at: Option<OffsetDateTime>,
106    ) -> Self {
107        let session = session
108            .or_else(|| env::var("ATUIN_SESSION").ok())
109            .unwrap_or_else(|| uuid_v7().as_simple().to_string());
110        let hostname = hostname.unwrap_or_else(get_host_user);
111
112        Self {
113            id: uuid_v7().as_simple().to_string().into(),
114            timestamp,
115            command,
116            cwd,
117            exit,
118            duration,
119            session,
120            hostname,
121            deleted_at,
122        }
123    }
124
125    pub fn serialize(&self) -> Result<DecryptedData> {
126        // This is pretty much the same as what we used for the old history, with one difference -
127        // it uses integers for timestamps rather than a string format.
128
129        use rmp::encode;
130
131        let mut output = vec![];
132
133        // write the version
134        encode::write_u16(&mut output, 0)?;
135        // INFO: ensure this is updated when adding new fields
136        encode::write_array_len(&mut output, 9)?;
137
138        encode::write_str(&mut output, &self.id.0)?;
139        encode::write_u64(&mut output, self.timestamp.unix_timestamp_nanos() as u64)?;
140        encode::write_sint(&mut output, self.duration)?;
141        encode::write_sint(&mut output, self.exit)?;
142        encode::write_str(&mut output, &self.command)?;
143        encode::write_str(&mut output, &self.cwd)?;
144        encode::write_str(&mut output, &self.session)?;
145        encode::write_str(&mut output, &self.hostname)?;
146
147        match self.deleted_at {
148            Some(d) => encode::write_u64(&mut output, d.unix_timestamp_nanos() as u64)?,
149            None => encode::write_nil(&mut output)?,
150        }
151
152        Ok(DecryptedData(output))
153    }
154
155    fn deserialize_v0(bytes: &[u8]) -> Result<History> {
156        use rmp::decode;
157
158        fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {
159            eyre!("{err:?}")
160        }
161
162        let mut bytes = Bytes::new(bytes);
163
164        let version = decode::read_u16(&mut bytes).map_err(error_report)?;
165
166        if version != 0 {
167            bail!("expected decoding v0 record, found v{version}");
168        }
169
170        let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?;
171
172        if nfields != 9 {
173            bail!("cannot decrypt history from a different version of Atuin");
174        }
175
176        let bytes = bytes.remaining_slice();
177        let (id, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
178
179        let mut bytes = Bytes::new(bytes);
180        let timestamp = decode::read_u64(&mut bytes).map_err(error_report)?;
181        let duration = decode::read_int(&mut bytes).map_err(error_report)?;
182        let exit = decode::read_int(&mut bytes).map_err(error_report)?;
183
184        let bytes = bytes.remaining_slice();
185        let (command, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
186        let (cwd, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
187        let (session, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
188        let (hostname, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
189
190        // if we have more fields, try and get the deleted_at
191        let mut bytes = Bytes::new(bytes);
192
193        let (deleted_at, bytes) = match decode::read_u64(&mut bytes) {
194            Ok(unix) => (Some(unix), bytes.remaining_slice()),
195            // we accept null here
196            Err(ValueReadError::TypeMismatch(Marker::Null)) => (None, bytes.remaining_slice()),
197            Err(err) => return Err(error_report(err)),
198        };
199
200        if !bytes.is_empty() {
201            bail!("trailing bytes in encoded history. malformed")
202        }
203
204        Ok(History {
205            id: id.to_owned().into(),
206            timestamp: OffsetDateTime::from_unix_timestamp_nanos(timestamp as i128)?,
207            duration,
208            exit,
209            command: command.to_owned(),
210            cwd: cwd.to_owned(),
211            session: session.to_owned(),
212            hostname: hostname.to_owned(),
213            deleted_at: deleted_at
214                .map(|t| OffsetDateTime::from_unix_timestamp_nanos(t as i128))
215                .transpose()?,
216        })
217    }
218
219    pub fn deserialize(bytes: &[u8], version: &str) -> Result<History> {
220        match version {
221            HISTORY_VERSION => Self::deserialize_v0(bytes),
222
223            _ => bail!("unknown version {version:?}"),
224        }
225    }
226
227    /// Builder for a history entry that is imported from shell history.
228    ///
229    /// The only two required fields are `timestamp` and `command`.
230    ///
231    /// ## Examples
232    /// ```
233    /// use atuin_client::history::History;
234    ///
235    /// let history: History = History::import()
236    ///     .timestamp(time::OffsetDateTime::now_utc())
237    ///     .command("ls -la")
238    ///     .build()
239    ///     .into();
240    /// ```
241    ///
242    /// If shell history contains more information, it can be added to the builder:
243    /// ```
244    /// use atuin_client::history::History;
245    ///
246    /// let history: History = History::import()
247    ///     .timestamp(time::OffsetDateTime::now_utc())
248    ///     .command("ls -la")
249    ///     .cwd("/home/user")
250    ///     .exit(0)
251    ///     .duration(100)
252    ///     .build()
253    ///     .into();
254    /// ```
255    ///
256    /// Unknown command or command without timestamp cannot be imported, which
257    /// is forced at compile time:
258    ///
259    /// ```compile_fail
260    /// use atuin_client::history::History;
261    ///
262    /// // this will not compile because timestamp is missing
263    /// let history: History = History::import()
264    ///     .command("ls -la")
265    ///     .build()
266    ///     .into();
267    /// ```
268    pub fn import() -> builder::HistoryImportedBuilder {
269        builder::HistoryImported::builder()
270    }
271
272    /// Builder for a history entry that is captured via hook.
273    ///
274    /// This builder is used only at the `start` step of the hook,
275    /// so it doesn't have any fields which are known only after
276    /// the command is finished, such as `exit` or `duration`.
277    ///
278    /// ## Examples
279    /// ```rust
280    /// use atuin_client::history::History;
281    ///
282    /// let history: History = History::capture()
283    ///     .timestamp(time::OffsetDateTime::now_utc())
284    ///     .command("ls -la")
285    ///     .cwd("/home/user")
286    ///     .build()
287    ///     .into();
288    /// ```
289    ///
290    /// Command without any required info cannot be captured, which is forced at compile time:
291    ///
292    /// ```compile_fail
293    /// use atuin_client::history::History;
294    ///
295    /// // this will not compile because `cwd` is missing
296    /// let history: History = History::capture()
297    ///     .timestamp(time::OffsetDateTime::now_utc())
298    ///     .command("ls -la")
299    ///     .build()
300    ///     .into();
301    /// ```
302    pub fn capture() -> builder::HistoryCapturedBuilder {
303        builder::HistoryCaptured::builder()
304    }
305
306    /// Builder for a history entry that is captured via hook, and sent to the daemon.
307    ///
308    /// This builder is used only at the `start` step of the hook,
309    /// so it doesn't have any fields which are known only after
310    /// the command is finished, such as `exit` or `duration`.
311    ///
312    /// It does, however, include information that can usually be inferred.
313    ///
314    /// This is because the daemon we are sending a request to lacks the context of the command
315    ///
316    /// ## Examples
317    /// ```rust
318    /// use atuin_client::history::History;
319    ///
320    /// let history: History = History::daemon()
321    ///     .timestamp(time::OffsetDateTime::now_utc())
322    ///     .command("ls -la")
323    ///     .cwd("/home/user")
324    ///     .session("018deb6e8287781f9973ef40e0fde76b")
325    ///     .hostname("computer:ellie")
326    ///     .build()
327    ///     .into();
328    /// ```
329    ///
330    /// Command without any required info cannot be captured, which is forced at compile time:
331    ///
332    /// ```compile_fail
333    /// use atuin_client::history::History;
334    ///
335    /// // this will not compile because `hostname` is missing
336    /// let history: History = History::daemon()
337    ///     .timestamp(time::OffsetDateTime::now_utc())
338    ///     .command("ls -la")
339    ///     .cwd("/home/user")
340    ///     .session("018deb6e8287781f9973ef40e0fde76b")
341    ///     .build()
342    ///     .into();
343    /// ```
344    pub fn daemon() -> builder::HistoryDaemonCaptureBuilder {
345        builder::HistoryDaemonCapture::builder()
346    }
347
348    /// Builder for a history entry that is imported from the database.
349    ///
350    /// All fields are required, as they are all present in the database.
351    ///
352    /// ```compile_fail
353    /// use atuin_client::history::History;
354    ///
355    /// // this will not compile because `id` field is missing
356    /// let history: History = History::from_db()
357    ///     .timestamp(time::OffsetDateTime::now_utc())
358    ///     .command("ls -la".to_string())
359    ///     .cwd("/home/user".to_string())
360    ///     .exit(0)
361    ///     .duration(100)
362    ///     .session("somesession".to_string())
363    ///     .hostname("localhost".to_string())
364    ///     .deleted_at(None)
365    ///     .build()
366    ///     .into();
367    /// ```
368    pub fn from_db() -> builder::HistoryFromDbBuilder {
369        builder::HistoryFromDb::builder()
370    }
371
372    pub fn success(&self) -> bool {
373        self.exit == 0 || self.duration == -1
374    }
375
376    pub fn should_save(&self, settings: &Settings) -> bool {
377        let secret_regex = SECRET_PATTERNS.iter().map(|f| f.1);
378        let secret_regex = RegexSet::new(secret_regex).expect("Failed to build secrets regex");
379
380        !(self.command.starts_with(' ')
381            || settings.history_filter.is_match(&self.command)
382            || settings.cwd_filter.is_match(&self.cwd)
383            || (secret_regex.is_match(&self.command)) && settings.secrets_filter)
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use regex::RegexSet;
390    use time::macros::datetime;
391
392    use crate::{history::HISTORY_VERSION, settings::Settings};
393
394    use super::History;
395
396    // Test that we don't save history where necessary
397    #[test]
398    fn privacy_test() {
399        let settings = Settings {
400            cwd_filter: RegexSet::new(["^/supasecret"]).unwrap(),
401            history_filter: RegexSet::new(["^psql"]).unwrap(),
402            ..Settings::utc()
403        };
404
405        let normal_command: History = History::capture()
406            .timestamp(time::OffsetDateTime::now_utc())
407            .command("echo foo")
408            .cwd("/")
409            .build()
410            .into();
411
412        let with_space: History = History::capture()
413            .timestamp(time::OffsetDateTime::now_utc())
414            .command(" echo bar")
415            .cwd("/")
416            .build()
417            .into();
418
419        let stripe_key: History = History::capture()
420            .timestamp(time::OffsetDateTime::now_utc())
421            .command("curl foo.com/bar?key=sk_test_1234567890abcdefghijklmnop")
422            .cwd("/")
423            .build()
424            .into();
425
426        let secret_dir: History = History::capture()
427            .timestamp(time::OffsetDateTime::now_utc())
428            .command("echo ohno")
429            .cwd("/supasecret")
430            .build()
431            .into();
432
433        let with_psql: History = History::capture()
434            .timestamp(time::OffsetDateTime::now_utc())
435            .command("psql")
436            .cwd("/supasecret")
437            .build()
438            .into();
439
440        assert!(normal_command.should_save(&settings));
441        assert!(!with_space.should_save(&settings));
442        assert!(!stripe_key.should_save(&settings));
443        assert!(!secret_dir.should_save(&settings));
444        assert!(!with_psql.should_save(&settings));
445    }
446
447    #[test]
448    fn disable_secrets() {
449        let settings = Settings {
450            secrets_filter: false,
451            ..Settings::utc()
452        };
453
454        let stripe_key: History = History::capture()
455            .timestamp(time::OffsetDateTime::now_utc())
456            .command("curl foo.com/bar?key=sk_test_1234567890abcdefghijklmnop")
457            .cwd("/")
458            .build()
459            .into();
460
461        assert!(stripe_key.should_save(&settings));
462    }
463
464    #[test]
465    fn test_serialize_deserialize() {
466        let bytes = [
467            205, 0, 0, 153, 217, 32, 54, 54, 100, 49, 54, 99, 98, 101, 101, 55, 99, 100, 52, 55,
468            53, 51, 56, 101, 53, 99, 53, 98, 56, 98, 52, 52, 101, 57, 48, 48, 54, 101, 207, 23, 99,
469            98, 117, 24, 210, 246, 128, 206, 2, 238, 210, 240, 0, 170, 103, 105, 116, 32, 115, 116,
470            97, 116, 117, 115, 217, 42, 47, 85, 115, 101, 114, 115, 47, 99, 111, 110, 114, 97, 100,
471            46, 108, 117, 100, 103, 97, 116, 101, 47, 68, 111, 99, 117, 109, 101, 110, 116, 115,
472            47, 99, 111, 100, 101, 47, 97, 116, 117, 105, 110, 217, 32, 98, 57, 55, 100, 57, 97,
473            51, 48, 54, 102, 50, 55, 52, 52, 55, 51, 97, 50, 48, 51, 100, 50, 101, 98, 97, 52, 49,
474            102, 57, 52, 53, 55, 187, 102, 118, 102, 103, 57, 51, 54, 99, 48, 107, 112, 102, 58,
475            99, 111, 110, 114, 97, 100, 46, 108, 117, 100, 103, 97, 116, 101, 192,
476        ];
477
478        let history = History {
479            id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
480            timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
481            duration: 49206000,
482            exit: 0,
483            command: "git status".to_owned(),
484            cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
485            session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
486            hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
487            deleted_at: None,
488        };
489
490        let serialized = history.serialize().expect("failed to serialize history");
491        assert_eq!(serialized.0, bytes);
492
493        let deserialized = History::deserialize(&serialized.0, HISTORY_VERSION)
494            .expect("failed to deserialize history");
495        assert_eq!(history, deserialized);
496
497        // test the snapshot too
498        let deserialized =
499            History::deserialize(&bytes, HISTORY_VERSION).expect("failed to deserialize history");
500        assert_eq!(history, deserialized);
501    }
502
503    #[test]
504    fn test_serialize_deserialize_deleted() {
505        let history = History {
506            id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
507            timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
508            duration: 49206000,
509            exit: 0,
510            command: "git status".to_owned(),
511            cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
512            session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
513            hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
514            deleted_at: Some(datetime!(2023-11-19 20:18 +00:00)),
515        };
516
517        let serialized = history.serialize().expect("failed to serialize history");
518
519        let deserialized = History::deserialize(&serialized.0, HISTORY_VERSION)
520            .expect("failed to deserialize history");
521
522        assert_eq!(history, deserialized);
523    }
524
525    #[test]
526    fn test_serialize_deserialize_version() {
527        // v0
528        let bytes_v0 = [
529            205, 0, 0, 153, 217, 32, 54, 54, 100, 49, 54, 99, 98, 101, 101, 55, 99, 100, 52, 55,
530            53, 51, 56, 101, 53, 99, 53, 98, 56, 98, 52, 52, 101, 57, 48, 48, 54, 101, 207, 23, 99,
531            98, 117, 24, 210, 246, 128, 206, 2, 238, 210, 240, 0, 170, 103, 105, 116, 32, 115, 116,
532            97, 116, 117, 115, 217, 42, 47, 85, 115, 101, 114, 115, 47, 99, 111, 110, 114, 97, 100,
533            46, 108, 117, 100, 103, 97, 116, 101, 47, 68, 111, 99, 117, 109, 101, 110, 116, 115,
534            47, 99, 111, 100, 101, 47, 97, 116, 117, 105, 110, 217, 32, 98, 57, 55, 100, 57, 97,
535            51, 48, 54, 102, 50, 55, 52, 52, 55, 51, 97, 50, 48, 51, 100, 50, 101, 98, 97, 52, 49,
536            102, 57, 52, 53, 55, 187, 102, 118, 102, 103, 57, 51, 54, 99, 48, 107, 112, 102, 58,
537            99, 111, 110, 114, 97, 100, 46, 108, 117, 100, 103, 97, 116, 101, 192,
538        ];
539
540        // some other version
541        let bytes_v1 = [
542            205, 1, 0, 153, 217, 32, 54, 54, 100, 49, 54, 99, 98, 101, 101, 55, 99, 100, 52, 55,
543            53, 51, 56, 101, 53, 99, 53, 98, 56, 98, 52, 52, 101, 57, 48, 48, 54, 101, 207, 23, 99,
544            98, 117, 24, 210, 246, 128, 206, 2, 238, 210, 240, 0, 170, 103, 105, 116, 32, 115, 116,
545            97, 116, 117, 115, 217, 42, 47, 85, 115, 101, 114, 115, 47, 99, 111, 110, 114, 97, 100,
546            46, 108, 117, 100, 103, 97, 116, 101, 47, 68, 111, 99, 117, 109, 101, 110, 116, 115,
547            47, 99, 111, 100, 101, 47, 97, 116, 117, 105, 110, 217, 32, 98, 57, 55, 100, 57, 97,
548            51, 48, 54, 102, 50, 55, 52, 52, 55, 51, 97, 50, 48, 51, 100, 50, 101, 98, 97, 52, 49,
549            102, 57, 52, 53, 55, 187, 102, 118, 102, 103, 57, 51, 54, 99, 48, 107, 112, 102, 58,
550            99, 111, 110, 114, 97, 100, 46, 108, 117, 100, 103, 97, 116, 101, 192,
551        ];
552
553        let deserialized = History::deserialize(&bytes_v0, HISTORY_VERSION);
554        assert!(deserialized.is_ok());
555
556        let deserialized = History::deserialize(&bytes_v1, HISTORY_VERSION);
557        assert!(deserialized.is_err());
558    }
559}