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#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
52pub struct History {
53 pub id: HistoryId,
57 pub timestamp: OffsetDateTime,
59 pub duration: i64,
61 pub exit: i64,
63 pub command: String,
65 pub cwd: String,
67 pub session: String,
69 pub hostname: String,
71 pub deleted_at: Option<OffsetDateTime>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
76pub struct HistoryStats {
77 pub next: Option<History>,
79 pub previous: Option<History>,
82
83 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 use rmp::encode;
130
131 let mut output = vec![];
132
133 encode::write_u16(&mut output, 0)?;
135 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 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 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 pub fn import() -> builder::HistoryImportedBuilder {
269 builder::HistoryImported::builder()
270 }
271
272 pub fn capture() -> builder::HistoryCapturedBuilder {
303 builder::HistoryCaptured::builder()
304 }
305
306 pub fn daemon() -> builder::HistoryDaemonCaptureBuilder {
345 builder::HistoryDaemonCapture::builder()
346 }
347
348 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]
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 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 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 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}