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#[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 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]
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 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 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 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}