atuin_client/
history.rs

1use core::fmt::Formatter;
2use rmp::decode::ValueReadError;
3use rmp::{Marker, decode::Bytes};
4use std::env;
5use std::fmt::Display;
6
7use atuin_common::record::DecryptedData;
8use atuin_common::utils::uuid_v7;
9
10use eyre::{Result, bail, eyre};
11
12use crate::secrets::SECRET_PATTERNS_RE;
13use crate::settings::Settings;
14use crate::utils::get_host_user;
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        !(self.command.starts_with(' ')
378            || self.command.is_empty()
379            || settings.history_filter.is_match(&self.command)
380            || settings.cwd_filter.is_match(&self.cwd)
381            || (settings.secrets_filter && SECRET_PATTERNS_RE.is_match(&self.command)))
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use regex::RegexSet;
388    use time::macros::datetime;
389
390    use crate::{history::HISTORY_VERSION, settings::Settings};
391
392    use super::History;
393
394    // Test that we don't save history where necessary
395    #[test]
396    fn privacy_test() {
397        let settings = Settings {
398            cwd_filter: RegexSet::new(["^/supasecret"]).unwrap(),
399            history_filter: RegexSet::new(["^psql"]).unwrap(),
400            ..Settings::utc()
401        };
402
403        let normal_command: History = History::capture()
404            .timestamp(time::OffsetDateTime::now_utc())
405            .command("echo foo")
406            .cwd("/")
407            .build()
408            .into();
409
410        let with_space: History = History::capture()
411            .timestamp(time::OffsetDateTime::now_utc())
412            .command(" echo bar")
413            .cwd("/")
414            .build()
415            .into();
416
417        let empty: History = History::capture()
418            .timestamp(time::OffsetDateTime::now_utc())
419            .command("")
420            .cwd("/")
421            .build()
422            .into();
423
424        let stripe_key: History = History::capture()
425            .timestamp(time::OffsetDateTime::now_utc())
426            .command("curl foo.com/bar?key=sk_test_1234567890abcdefghijklmnop")
427            .cwd("/")
428            .build()
429            .into();
430
431        let secret_dir: History = History::capture()
432            .timestamp(time::OffsetDateTime::now_utc())
433            .command("echo ohno")
434            .cwd("/supasecret")
435            .build()
436            .into();
437
438        let with_psql: History = History::capture()
439            .timestamp(time::OffsetDateTime::now_utc())
440            .command("psql")
441            .cwd("/supasecret")
442            .build()
443            .into();
444
445        assert!(normal_command.should_save(&settings));
446        assert!(!with_space.should_save(&settings));
447        assert!(!empty.should_save(&settings));
448        assert!(!stripe_key.should_save(&settings));
449        assert!(!secret_dir.should_save(&settings));
450        assert!(!with_psql.should_save(&settings));
451    }
452
453    #[test]
454    fn disable_secrets() {
455        let settings = Settings {
456            secrets_filter: false,
457            ..Settings::utc()
458        };
459
460        let stripe_key: History = History::capture()
461            .timestamp(time::OffsetDateTime::now_utc())
462            .command("curl foo.com/bar?key=sk_test_1234567890abcdefghijklmnop")
463            .cwd("/")
464            .build()
465            .into();
466
467        assert!(stripe_key.should_save(&settings));
468    }
469
470    #[test]
471    fn test_serialize_deserialize() {
472        let bytes = [
473            205, 0, 0, 153, 217, 32, 54, 54, 100, 49, 54, 99, 98, 101, 101, 55, 99, 100, 52, 55,
474            53, 51, 56, 101, 53, 99, 53, 98, 56, 98, 52, 52, 101, 57, 48, 48, 54, 101, 207, 23, 99,
475            98, 117, 24, 210, 246, 128, 206, 2, 238, 210, 240, 0, 170, 103, 105, 116, 32, 115, 116,
476            97, 116, 117, 115, 217, 42, 47, 85, 115, 101, 114, 115, 47, 99, 111, 110, 114, 97, 100,
477            46, 108, 117, 100, 103, 97, 116, 101, 47, 68, 111, 99, 117, 109, 101, 110, 116, 115,
478            47, 99, 111, 100, 101, 47, 97, 116, 117, 105, 110, 217, 32, 98, 57, 55, 100, 57, 97,
479            51, 48, 54, 102, 50, 55, 52, 52, 55, 51, 97, 50, 48, 51, 100, 50, 101, 98, 97, 52, 49,
480            102, 57, 52, 53, 55, 187, 102, 118, 102, 103, 57, 51, 54, 99, 48, 107, 112, 102, 58,
481            99, 111, 110, 114, 97, 100, 46, 108, 117, 100, 103, 97, 116, 101, 192,
482        ];
483
484        let history = History {
485            id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
486            timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
487            duration: 49206000,
488            exit: 0,
489            command: "git status".to_owned(),
490            cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
491            session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
492            hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
493            deleted_at: None,
494        };
495
496        let serialized = history.serialize().expect("failed to serialize history");
497        assert_eq!(serialized.0, bytes);
498
499        let deserialized = History::deserialize(&serialized.0, HISTORY_VERSION)
500            .expect("failed to deserialize history");
501        assert_eq!(history, deserialized);
502
503        // test the snapshot too
504        let deserialized =
505            History::deserialize(&bytes, HISTORY_VERSION).expect("failed to deserialize history");
506        assert_eq!(history, deserialized);
507    }
508
509    #[test]
510    fn test_serialize_deserialize_deleted() {
511        let history = History {
512            id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
513            timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
514            duration: 49206000,
515            exit: 0,
516            command: "git status".to_owned(),
517            cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
518            session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
519            hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
520            deleted_at: Some(datetime!(2023-11-19 20:18 +00:00)),
521        };
522
523        let serialized = history.serialize().expect("failed to serialize history");
524
525        let deserialized = History::deserialize(&serialized.0, HISTORY_VERSION)
526            .expect("failed to deserialize history");
527
528        assert_eq!(history, deserialized);
529    }
530
531    #[test]
532    fn test_serialize_deserialize_version() {
533        // v0
534        let bytes_v0 = [
535            205, 0, 0, 153, 217, 32, 54, 54, 100, 49, 54, 99, 98, 101, 101, 55, 99, 100, 52, 55,
536            53, 51, 56, 101, 53, 99, 53, 98, 56, 98, 52, 52, 101, 57, 48, 48, 54, 101, 207, 23, 99,
537            98, 117, 24, 210, 246, 128, 206, 2, 238, 210, 240, 0, 170, 103, 105, 116, 32, 115, 116,
538            97, 116, 117, 115, 217, 42, 47, 85, 115, 101, 114, 115, 47, 99, 111, 110, 114, 97, 100,
539            46, 108, 117, 100, 103, 97, 116, 101, 47, 68, 111, 99, 117, 109, 101, 110, 116, 115,
540            47, 99, 111, 100, 101, 47, 97, 116, 117, 105, 110, 217, 32, 98, 57, 55, 100, 57, 97,
541            51, 48, 54, 102, 50, 55, 52, 52, 55, 51, 97, 50, 48, 51, 100, 50, 101, 98, 97, 52, 49,
542            102, 57, 52, 53, 55, 187, 102, 118, 102, 103, 57, 51, 54, 99, 48, 107, 112, 102, 58,
543            99, 111, 110, 114, 97, 100, 46, 108, 117, 100, 103, 97, 116, 101, 192,
544        ];
545
546        // some other version
547        let bytes_v1 = [
548            205, 1, 0, 153, 217, 32, 54, 54, 100, 49, 54, 99, 98, 101, 101, 55, 99, 100, 52, 55,
549            53, 51, 56, 101, 53, 99, 53, 98, 56, 98, 52, 52, 101, 57, 48, 48, 54, 101, 207, 23, 99,
550            98, 117, 24, 210, 246, 128, 206, 2, 238, 210, 240, 0, 170, 103, 105, 116, 32, 115, 116,
551            97, 116, 117, 115, 217, 42, 47, 85, 115, 101, 114, 115, 47, 99, 111, 110, 114, 97, 100,
552            46, 108, 117, 100, 103, 97, 116, 101, 47, 68, 111, 99, 117, 109, 101, 110, 116, 115,
553            47, 99, 111, 100, 101, 47, 97, 116, 117, 105, 110, 217, 32, 98, 57, 55, 100, 57, 97,
554            51, 48, 54, 102, 50, 55, 52, 52, 55, 51, 97, 50, 48, 51, 100, 50, 101, 98, 97, 52, 49,
555            102, 57, 52, 53, 55, 187, 102, 118, 102, 103, 57, 51, 54, 99, 48, 107, 112, 102, 58,
556            99, 111, 110, 114, 97, 100, 46, 108, 117, 100, 103, 97, 116, 101, 192,
557        ];
558
559        let deserialized = History::deserialize(&bytes_v0, HISTORY_VERSION);
560        assert!(deserialized.is_ok());
561
562        let deserialized = History::deserialize(&bytes_v1, HISTORY_VERSION);
563        assert!(deserialized.is_err());
564    }
565}