summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorEllie Huxtable <e@elm.sh>2021-04-13 19:14:07 +0100
committerGitHub <noreply@github.com>2021-04-13 19:14:07 +0100
commit5751463942cc91f1f1ffaf6e2ac633d7a0085f25 (patch)
treef7b5b9a4702c4c3ef29aa60d36612f61ffeae052 /src
parenta1fcf54f93fe5f48a3ffa6b619e8dca7dcdbc798 (diff)
Add history sync, resolves #13 (#31)
* Add encryption * Add login and register command * Add count endpoint * Write initial sync push * Add single sync command Confirmed working for one client only * Automatically sync on a configurable frequency * Add key command, key arg to login * Only load session if it exists * Use sync and history timestamps for download * Bind other key code Seems like some systems have this code for up arrow? I'm not sure why, and it's not an easy one to google. * Simplify upload * Try and fix download sync loop * Change sync order to avoid uploading what we just downloaded * Multiline import fix * Fix time parsing * Fix importing history with no time * Add hostname to sync * Use hostname to filter sync * Fixes * Add binding * Stuff from yesterday * Set cursor modes * Make clippy happy * Bump version
Diffstat (limited to 'src')
-rw-r--r--src/api.rs36
-rw-r--r--src/command/history.rs30
-rw-r--r--src/command/login.rs48
-rw-r--r--src/command/mod.rs34
-rw-r--r--src/command/register.rs54
-rw-r--r--src/command/search.rs3
-rw-r--r--src/command/server.rs4
-rw-r--r--src/command/sync.rs15
-rw-r--r--src/local/api_client.rs94
-rw-r--r--src/local/database.rs55
-rw-r--r--src/local/encryption.rs108
-rw-r--r--src/local/history.rs11
-rw-r--r--src/local/import.rs118
-rw-r--r--src/local/mod.rs3
-rw-r--r--src/local/sync.rs135
-rw-r--r--src/main.rs19
-rw-r--r--src/remote/auth.rs92
-rw-r--r--src/remote/database.rs2
-rw-r--r--src/remote/models.rs16
-rw-r--r--src/remote/server.rs26
-rw-r--r--src/remote/views.rs144
-rw-r--r--src/schema.rs4
-rw-r--r--src/settings.rs131
-rw-r--r--src/shell/atuin.zsh26
-rw-r--r--src/utils.rs24
25 files changed, 1055 insertions, 177 deletions
diff --git a/src/api.rs b/src/api.rs
new file mode 100644
index 00000000..90977404
--- /dev/null
+++ b/src/api.rs
@@ -0,0 +1,36 @@
+use chrono::Utc;
+
+// This is shared between the client and the server, and has the data structures
+// representing the requests/responses for each method.
+// TODO: Properly define responses rather than using json!
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct RegisterRequest {
+ pub email: String,
+ pub username: String,
+ pub password: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct LoginRequest {
+ pub username: String,
+ pub password: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct AddHistoryRequest {
+ pub id: String,
+ pub timestamp: chrono::DateTime<Utc>,
+ pub data: String,
+ pub hostname: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct CountResponse {
+ pub count: i64,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct ListHistoryResponse {
+ pub history: Vec<String>,
+}
diff --git a/src/command/history.rs b/src/command/history.rs
index 05aed4b9..3b4a717c 100644
--- a/src/command/history.rs
+++ b/src/command/history.rs
@@ -1,10 +1,13 @@
use std::env;
use eyre::Result;
+use fork::{fork, Fork};
use structopt::StructOpt;
use crate::local::database::Database;
use crate::local::history::History;
+use crate::local::sync;
+use crate::settings::Settings;
#[derive(StructOpt)]
pub enum Cmd {
@@ -50,21 +53,13 @@ fn print_list(h: &[History]) {
}
impl Cmd {
- pub fn run(&self, db: &mut impl Database) -> Result<()> {
+ pub fn run(&self, settings: &Settings, db: &mut impl Database) -> Result<()> {
match self {
Self::Start { command: words } => {
let command = words.join(" ");
let cwd = env::current_dir()?.display().to_string();
- let h = History::new(
- chrono::Utc::now().timestamp_nanos(),
- command,
- cwd,
- -1,
- -1,
- None,
- None,
- );
+ let h = History::new(chrono::Utc::now(), command, cwd, -1, -1, None, None);
// print the ID
// we use this as the key for calling end
@@ -76,10 +71,23 @@ impl Cmd {
Self::End { id, exit } => {
let mut h = db.load(id)?;
h.exit = *exit;
- h.duration = chrono::Utc::now().timestamp_nanos() - h.timestamp;
+ h.duration = chrono::Utc::now().timestamp_nanos() - h.timestamp.timestamp_nanos();
db.update(&h)?;
+ if settings.local.should_sync()? {
+ match fork() {
+ Ok(Fork::Parent(child)) => {
+ debug!("launched sync background process with PID {}", child);
+ }
+ Ok(Fork::Child) => {
+ debug!("running periodic background sync");
+ sync::sync(settings, false, db)?;
+ }
+ Err(_) => println!("Fork failed"),
+ }
+ }
+
Ok(())
}
diff --git a/src/command/login.rs b/src/command/login.rs
new file mode 100644
index 00000000..4f58b77f
--- /dev/null
+++ b/src/command/login.rs
@@ -0,0 +1,48 @@
+use std::collections::HashMap;
+use std::fs::File;
+use std::io::prelude::*;
+
+use eyre::Result;
+use structopt::StructOpt;
+
+use crate::settings::Settings;
+
+#[derive(StructOpt)]
+#[structopt(setting(structopt::clap::AppSettings::DeriveDisplayOrder))]
+pub struct Cmd {
+ #[structopt(long, short)]
+ pub username: String,
+
+ #[structopt(long, short)]
+ pub password: String,
+
+ #[structopt(long, short, about = "the encryption key for your account")]
+ pub key: String,
+}
+
+impl Cmd {
+ pub fn run(&self, settings: &Settings) -> Result<()> {
+ let mut map = HashMap::new();
+ map.insert("username", self.username.clone());
+ map.insert("password", self.password.clone());
+
+ let url = format!("{}/login", settings.local.sync_address);
+ let client = reqwest::blocking::Client::new();
+ let resp = client.post(url).json(&map).send()?;
+
+ let session = resp.json::<HashMap<String, String>>()?;
+ let session = session["session"].clone();
+
+ let session_path = settings.local.session_path.as_str();
+ let mut file = File::create(session_path)?;
+ file.write_all(session.as_bytes())?;
+
+ let key_path = settings.local.key_path.as_str();
+ let mut file = File::create(key_path)?;
+ file.write_all(&base64::decode(self.key.clone())?)?;
+
+ println!("Logged in!");
+
+ Ok(())
+ }
+}
diff --git a/src/command/mod.rs b/src/command/mod.rs
index a5ea0228..eeb11a87 100644
--- a/src/command/mod.rs
+++ b/src/command/mod.rs
@@ -9,9 +9,12 @@ mod event;
mod history;
mod import;
mod init;
+mod login;
+mod register;
mod search;
mod server;
mod stats;
+mod sync;
#[derive(StructOpt)]
pub enum AtuinCmd {
@@ -38,6 +41,21 @@ pub enum AtuinCmd {
#[structopt(about = "interactive history search")]
Search { query: Vec<String> },
+
+ #[structopt(about = "sync with the configured server")]
+ Sync {
+ #[structopt(long, short, about = "force re-download everything")]
+ force: bool,
+ },
+
+ #[structopt(about = "login to the configured server")]
+ Login(login::Cmd),
+
+ #[structopt(about = "register with the configured server")]
+ Register(register::Cmd),
+
+ #[structopt(about = "print the encryption key for transfer to another machine")]
+ Key,
}
pub fn uuid_v4() -> String {
@@ -47,13 +65,27 @@ pub fn uuid_v4() -> String {
impl AtuinCmd {
pub fn run(self, db: &mut impl Database, settings: &Settings) -> Result<()> {
match self {
- Self::History(history) => history.run(db),
+ Self::History(history) => history.run(settings, db),
Self::Import(import) => import.run(db),
Self::Server(server) => server.run(settings),
Self::Stats(stats) => stats.run(db, settings),
Self::Init => init::init(),
Self::Search { query } => search::run(&query, db),
+ Self::Sync { force } => sync::run(settings, force, db),
+ Self::Login(l) => l.run(settings),
+ Self::Register(r) => register::run(
+ settings,
+ r.username.as_str(),
+ r.email.as_str(),
+ r.password.as_str(),
+ ),
+ Self::Key => {
+ let key = std::fs::read(settings.local.key_path.as_str())?;
+ println!("{}", base64::encode(key));
+ Ok(())
+ }
+
Self::Uuid => {
println!("{}", uuid_v4());
Ok(())
diff --git a/src/command/register.rs b/src/command/register.rs
new file mode 100644
index 00000000..62bbeaeb
--- /dev/null
+++ b/src/command/register.rs
@@ -0,0 +1,54 @@
+use std::collections::HashMap;
+use std::fs::File;
+use std::io::prelude::*;
+
+use eyre::{eyre, Result};
+use structopt::StructOpt;
+
+use crate::settings::Settings;
+
+#[derive(StructOpt)]
+#[structopt(setting(structopt::clap::AppSettings::DeriveDisplayOrder))]
+pub struct Cmd {
+ #[structopt(long, short)]
+ pub username: String,
+
+ #[structopt(long, short)]
+ pub email: String,
+
+ #[structopt(long, short)]
+ pub password: String,
+}
+
+pub fn run(settings: &Settings, username: &str, email: &str, password: &str) -> Result<()> {
+ let mut map = HashMap::new();
+ map.insert("username", username);
+ map.insert("email", email);
+ map.insert("password", password);
+
+ let url = format!("{}/user/{}", settings.local.sync_address, username);
+ let resp = reqwest::blocking::get(url)?;
+
+ if resp.status().is_success() {
+ println!("Username is already in use! Please try another.");
+ return Ok(());
+ }
+
+ let url = format!("{}/register", settings.local.sync_address);
+ let client = reqwest::blocking::Client::new();
+ let resp = client.post(url).json(&map).send()?;
+
+ if !resp.status().is_success() {
+ println!("Failed to register user - please check your details and try again");
+ return Err(eyre!("failed to register user"));
+ }
+
+ let session = resp.json::<HashMap<String, String>>()?;
+ let session = session["session"].clone();
+
+ let path = settings.local.session_path.as_str();
+ let mut file = File::create(path)?;
+ file.write_all(session.as_bytes())?;
+
+ Ok(())
+}
diff --git a/src/command/search.rs b/src/command/search.rs
index d51e29ef..b9f3987c 100644
--- a/src/command/search.rs
+++ b/src/command/search.rs
@@ -171,7 +171,8 @@ fn select_history(query: &[String], db: &mut impl Database) -> Result<String> {
.iter()
.enumerate()
.map(|(i, m)| {
- let mut content = Span::raw(m.command.to_string());
+ let mut content =
+ Span::raw(m.command.to_string().replace("\n", " ").replace("\t", " "));
if let Some(selected) = app.results_state.selected() {
if selected == i {
diff --git a/src/command/server.rs b/src/command/server.rs
index 5156f409..ba2a9a2f 100644
--- a/src/command/server.rs
+++ b/src/command/server.rs
@@ -24,10 +24,10 @@ impl Cmd {
match self {
Self::Start { host, port } => {
let host = host.as_ref().map_or(
- settings.remote.host.clone(),
+ settings.server.host.clone(),
std::string::ToString::to_string,
);
- let port = port.map_or(settings.remote.port, |p| p);
+ let port = port.map_or(settings.server.port, |p| p);
server::launch(settings, host, port);
}
diff --git a/src/command/sync.rs b/src/command/sync.rs
new file mode 100644
index 00000000..facbe578
--- /dev/null
+++ b/src/command/sync.rs
@@ -0,0 +1,15 @@
+use eyre::Result;
+
+use crate::local::database::Database;
+use crate::local::sync;
+use crate::settings::Settings;
+
+pub fn run(settings: &Settings, force: bool, db: &mut impl Database) -> Result<()> {
+ sync::sync(settings, force, db)?;
+ println!(
+ "Sync complete! {} items in database, force: {}",
+ db.history_count()?,
+ force
+ );
+ Ok(())
+}
diff --git a/src/local/api_client.rs b/src/local/api_client.rs
new file mode 100644
index 00000000..434c07ba
--- /dev/null
+++ b/src/local/api_client.rs
@@ -0,0 +1,94 @@
+use chrono::Utc;
+use eyre::Result;
+use reqwest::header::AUTHORIZATION;
+
+use crate::api::{AddHistoryRequest, CountResponse, ListHistoryResponse};
+use crate::local::encryption::{decrypt, load_key};
+use crate::local::history::History;
+use crate::settings::Settings;
+use crate::utils::hash_str;
+
+pub struct Client<'a> {
+ settings: &'a Settings,
+}
+
+impl<'a> Client<'a> {
+ pub const fn new(settings: &'a Settings) -> Self {
+ Client { settings }
+ }
+
+ pub fn count(&self) -> Result<i64> {
+ let url = format!("{}/sync/count", self.settings.local.sync_address);
+ let client = reqwest::blocking::Client::new();
+
+ let resp = client
+ .get(url)
+ .header(
+ AUTHORIZATION,
+ format!("Token {}", self.settings.local.session_token),
+ )
+ .send()?;
+
+ let count = resp.json::<CountResponse>()?;
+
+ Ok(count.count)
+ }
+
+ pub fn get_history(
+ &self,
+ sync_ts: chrono::DateTime<Utc>,
+ history_ts: chrono::DateTime<Utc>,
+ host: Option<String>,
+ ) -> Result<Vec<History>> {
+ let key = load_key(self.settings)?;
+
+ let host = match host {
+ None => hash_str(&format!("{}:{}", whoami::hostname(), whoami::username())),
+ Some(h) => h,
+ };
+
+ // this allows for syncing between users on the same machine
+ let url = format!(
+ "{}/sync/history?sync_ts={}&history_ts={}&host={}",
+ self.settings.local.sync_address,
+ sync_ts.to_rfc3339(),
+ history_ts.to_rfc3339(),
+ host,
+ );
+ let client = reqwest::blocking::Client::new();
+
+ let resp = client
+ .get(url)
+ .header(
+ AUTHORIZATION,
+ format!("Token {}", self.settings.local.session_token),
+ )
+ .send()?;
+
+ let history = resp.json::<ListHistoryResponse>()?;
+ let history = history
+ .history
+ .iter()
+ .map(|h| serde_json::from_str(h).expect("invalid base64"))
+ .map(|h| decrypt(&h, &key).expect("failed to decrypt history! check your key"))
+ .collect();
+
+ Ok(history)
+ }
+
+ pub fn post_history(&self, history: &[AddHistoryRequest]) -> Result<()> {
+ let client = reqwest::blocking::Client::new();
+
+ let url = format!("{}/history", self.settings.local.sync_address);
+ client
+ .post(url)
+ .json(history)
+ .header(
+ AUTHORIZATION,
+ format!("Token {}", self.settings.local.session_token),
+ )
+ .send()?;
+
+ Ok(())
+ }
+}
diff --git a/src/local/database.rs b/src/local/database.rs
index ad7078e5..977f11cc 100644
--- a/src/local/database.rs
+++ b/src/local/database.rs
@@ -1,3 +1,4 @@
+use chrono::prelude::*;
use chrono::Utc;
use std::path::Path;
@@ -21,6 +22,10 @@ pub trait Database {
fn update(&self, h: &History) -> Result<()>;
fn history_count(&self) -> Result<i64>;
+ fn first(&self) -> Result<History>;
+ fn last(&self) -> Result<History>;
+ fn before(&self, timestamp: chrono::DateTime<Utc>, count: i64) -> Result<Vec<History>>;
+
fn prefix_search(&self, query: &str) -> Result<Vec<History>>;
}
@@ -44,9 +49,7 @@ impl Sqlite {
let conn = Connection::open(path)?;
- if create {
- Self::setup_db(&conn)?;
- }
+ Self::setup_db(&conn)?;
Ok(Self { conn })
}
@@ -70,6 +73,14 @@ impl Sqlite {
[],
)?;
+ conn.execute(
+ "create table if not exists history_encrypted (
+ id text primary key,
+ data blob not null
+ )",
+ [],
+ )?;
+
Ok(())
}
@@ -87,7 +98,7 @@ impl Sqlite {
) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
h.id,
- h.timestamp,
+ h.timestamp.timestamp_nanos(),
h.duration,
h.exit,
h.command,
@@ -146,7 +157,7 @@ impl Database for Sqlite {
"update history
set timestamp = ?2, duration = ?3, exit = ?4, command = ?5, cwd = ?6, session = ?7, hostname = ?8
where id = ?1",
- params![h.id, h.timestamp, h.duration, h.exit, h.command, h.cwd, h.session, h.hostname],
+ params![h.id, h.timestamp.timestamp_nanos(), h.duration, h.exit, h.command, h.cwd, h.session, h.hostname],
)?;
Ok(())
@@ -183,6 +194,38 @@ impl Database for Sqlite {
Ok(history_iter.filter_map(Result::ok).collect())
}
+ fn first(&self) -> Result<History> {
+ let mut stmt = self
+ .conn
+ .prepare("SELECT * FROM history order by timestamp asc limit 1")?;
+
+ let history = stmt.query_row(params![], |row| history_from_sqlite_row(None, row))?;
+
+ Ok(history)
+ }
+
+ fn last(&self) -> Result<History> {
+ let mut stmt = self
+ .conn
+ .prepare("SELECT * FROM history order by timestamp desc limit 1")?;
+
+ let history = stmt.query_row(params![], |row| history_from_sqlite_row(None, row))?;
+
+ Ok(history)
+ }
+
+ fn before(&self, timestamp: chrono::DateTime<Utc>, count: i64) -> Result<Vec<History>> {
+ let mut stmt = self.conn.prepare(
+ "SELECT * FROM history where timestamp <= ? order by timestamp desc limit ?",
+ )?;
+
+ let history_iter = stmt.query_map(params![timestamp.timestamp_nanos(), count], |row| {
+ history_from_sqlite_row(None, row)
+ })?;
+
+ Ok(history_iter.filter_map(Result::ok).collect())
+ }
+
fn query(&self, query: &str, params: impl Params) -> Result<Vec<History>> {
let mut stmt = self.conn.prepare(query)?;
@@ -218,7 +261,7 @@ fn history_from_sqlite_row(
Ok(History {
id,
- timestamp: row.get(1)?,
+ timestamp: Utc.timestamp_nanos(row.get(1)?),
duration: row.get(2)?,
exit: row.get(3)?,
command: row.get(4)?,
diff --git a/src/local/encryption.rs b/src/local/encryption.rs
new file mode 100644
index 00000000..3c1699e3
--- /dev/null
+++ b/src/local/encryption.rs
@@ -0,0 +1,108 @@
+// The general idea is that we NEVER send cleartext history to the server
+// This way the odds of anything private ending up where it should not are
+// very low
+// The server authenticates via the usual username and password. This has
+// nothing to do with the encryption, and is purely authentication! The client
+// generates its own secret key, and encrypts all shell history with libsodium's
+// secretbox. The data is then sent to the server, where it is stored. All
+// clients must share the secret in order to be able to sync, as it is needed
+// to decrypt
+
+use std::fs::File;
+use std::io::prelude::*;
+use std::path::PathBuf;
+
+use eyre::{eyre, Result};
+use sodiumoxide::crypto::secretbox;
+
+use crate::local::history::History;
+use crate::settings::Settings;
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct EncryptedHistory {
+ pub ciphertext: Vec<u8>,
+ pub nonce: secretbox::Nonce,
+}
+
+// Loads the secret key, will create + save if it doesn't exist
+pub fn load_key(settings: &Settings) -> Result<secretbox::Key> {
+ let path = settings.local.key_path.as_str();
+
+ if PathBuf::from(path).exists() {
+ let bytes = std::fs::read(path)?;
+ let key: secretbox::Key = rmp_serde::from_read_ref(&bytes)?;
+ Ok(key)
+ } else {
+ let key = secretbox::gen_key();
+ let buf = rmp_serde::to_vec(&key)?;
+
+ let mut file = File::create(path)?;
+ file.write_all(&buf)?;
+
+ Ok(key)
+ }
+}
+
+pub fn encrypt(history: &History, key: &secretbox::Key) -> Result<EncryptedHistory> {
+ // serialize with msgpack
+ let buf = rmp_serde::to_vec(history)?;
+
+ let nonce = secretbox::gen_nonce();
+
+ let ciphertext = secretbox::seal(&buf, &nonce, key);
+
+ Ok(EncryptedHistory { ciphertext, nonce })
+}
+
+pub fn decrypt(encrypted_history: &EncryptedHistory, key: &secretbox::Key) -> Result<History> {
+ let plaintext = secretbox::open(&encrypted_history.ciphertext, &encrypted_history.nonce, key)
+ .map_err(|_| eyre!("failed to open secretbox - invalid key?"))?;
+
+ let history = rmp_serde::from_read_ref(&plaintext)?;
+
+ Ok(history)
+}
+
+#[cfg(test)]
+mod test {
+ use sodiumoxide::crypto::secretbox;
+
+ use crate::local::history::History;
+
+ use super::{decrypt, encrypt};
+
+ #[test]
+ fn test_encrypt_decrypt() {
+ let key1 = secretbox::gen_key();
+ let key2 = secretbox::gen_key();
+
+ 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()),
+ );
+
+ let e1 = encrypt(&history, &key1).unwrap();
+ let e2 = encrypt(&history, &key2).unwrap();
+
+ assert_ne!(e1.ciphertext, e2.ciphertext);
+ assert_ne!(e1.nonce, e2.nonce);
+
+ // test decryption works
+ // this should pass
+ match decrypt(&e1, &key1) {
+ Err(e) => assert!(false, "failed to decrypt, got {}", e),
+ Ok(h) => assert_eq!(h, history),
+ };
+
+ // this should err
+ match decrypt(&e2, &key1) {
+ Ok(_) => assert!(false, "expected an error decrypting with invalid key"),
+ Err(_) => {}
+ };
+ }
+}
diff --git a/src/local/history.rs b/src/local/history.rs
index 0ca112bd..1712f8b9 100644
--- a/src/local/history.rs
+++ b/src/local/history.rs
@@ -1,12 +1,15 @@
use std::env;
use std::hash::{Hash, Hasher};
+use chrono::Utc;
+
use crate::command::uuid_v4;
-#[derive(Debug, Clone)]
+// Any new fields MUST be Optional<>!
+#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct History {
pub id: String,
- pub timestamp: i64,
+ pub timestamp: chrono::DateTime<Utc>,
pub duration: i64,
pub exit: i64,
pub command: String,
@@ -17,7 +20,7 @@ pub struct History {
impl History {
pub fn new(
- timestamp: i64,
+ timestamp: chrono::DateTime<Utc>,
command: String,
cwd: String,
exit: i64,
@@ -29,7 +32,7 @@ impl History {
.or_else(|| env::var("ATUIN_SESSION").ok())
.unwrap_or_else(uuid_v4);
let hostname =
- hostname.unwrap_or_else(|| hostname::get().unwrap().to_str().unwrap().to_string());
+ hostname.unwrap_or_else(|| format!("{}:{}", whoami::hostname(), whoami::username()));
Self {
id: uuid_v4(),
diff --git a/src/local/import.rs b/src/local/import.rs
index 9bf79c72..d0f679c9 100644
--- a/src/local/import.rs
+++ b/src/local/import.rs
@@ -4,7 +4,9 @@
use std::io::{BufRead, BufReader, Seek, SeekFrom};
use std::{fs::File, path::Path};
-use eyre::{Result, WrapErr};
+use chrono::prelude::*;
+use chrono::Utc;
+use eyre::{eyre, Result};
use super::history::History;
@@ -13,6 +15,7 @@ pub struct Zsh {
file: BufReader<File>,
pub loc: u64,
+ pub counter: i64,
}
// this could probably be sped up
@@ -32,19 +35,23 @@ impl Zsh {
Ok(Self {
file: buf,
loc: loc as u64,
+ counter: 0,
})
}
}
-fn parse_extended(line: &str) -> History {
+fn parse_extended(line: &str, counter: i64) -> History {
let line = line.replacen(": ", "", 2);
let (time, duration) = line.split_once(':').unwrap();
let (duration, command) = duration.split_once(';').unwrap();
- let time = time.parse::<i64>().map_or_else(
- |_| chrono::Utc::now().timestamp_nanos(),
- |t| t * 1_000_000_000,
- );
+ let time = time
+ .parse::<i64>()
+ .unwrap_or_else(|_| chrono::Utc::now().timestamp());
+
+ let offset = chrono::Duration::milliseconds(counter);
+ let time = Utc.timestamp(time, 0);
+ let time = time + offset;
let duration = duration.parse::<i64>().map_or(-1, |t| t * 1_000_000_000);
@@ -60,6 +67,18 @@ fn parse_extended(line: &str) -> History {
)
}
+impl Zsh {
+ fn read_line(&mut self) -> Option<Result<String>> {
+ let mut line = String::new();
+
+ match self.file.read_line(&mut line) {
+ Ok(0) => None,
+ Ok(_) => Some(Ok(line)),
+ Err(e) => Some(Err(eyre!("failed to read line: {}", e))), // we can skip past things like invalid utf8
+ }
+ }
+}
+
impl Iterator for Zsh {
type Item = Result<History>;
@@ -68,54 +87,89 @@ impl Iterator for Zsh {
// These lines begin with :
// So, if the line begins with :, parse it. Otherwise it's just
// the command
- let mut line = String::new();
+ let line = self.read_line()?;
- match self.file.read_line(&mut line) {
- Ok(0) => None,
- Ok(_) => {
- let extended = line.starts_with(':');
-
- if extended {
- Some(Ok(parse_extended(line.as_str())))
- } else {
- Some(Ok(History::new(
- chrono::Utc::now().timestamp_nanos(), // what else? :/
- line.trim_end().to_string(),
- String::from("unknown"),
- -1,
- -1,
- None,
- None,
- )))
- }
+ if let Err(e) = line {
+ return Some(Err(e)); // :(
+ }
+
+ let mut line = line.unwrap();
+
+ while line.ends_with("\\\n") {
+ let next_line = self.read_line()?;
+
+ if next_line.is_err() {
+ // There's a chance that the last line of a command has invalid
+ // characters, the only safe thing to do is break :/
+ // usually just invalid utf8 or smth