From 966a80c4199a49898cc7d8641012d520ce6b2efa Mon Sep 17 00:00:00 2001 From: Benedikt Peetz Date: Mon, 20 Jul 2026 19:30:40 +0200 Subject: chore: Commit --- Cargo.lock | 122 +------- Cargo.toml | 2 + crates/client/Cargo.toml | 1 + crates/client/src/command/client/history/start.rs | 5 +- crates/daemon/Cargo.toml | 1 + crates/daemon/build.rs | 32 --- crates/daemon/proto/control.proto | 33 --- crates/daemon/proto/history.proto | 78 ----- crates/daemon/src/aclient/database/mod.rs | 19 +- crates/daemon/src/aclient/history/builder.rs | 154 ---------- crates/daemon/src/aclient/history/mod.rs | 329 +--------------------- crates/daemon/src/aclient/history/store.rs | 4 +- crates/daemon/src/aclient/mod.rs | 11 +- crates/daemon/src/aclient/ordering.rs | 3 +- crates/daemon/src/aclient/secrets.rs | 223 --------------- crates/daemon/src/aclient/utils.rs | 15 - crates/daemon/src/api/client/mod.rs | 272 ------------------ crates/daemon/src/api/control.rs | 280 ++++++++++++++++++ crates/daemon/src/api/generated.rs | 28 -- crates/daemon/src/api/history.rs | 253 +++++++++++++++++ crates/daemon/src/api/mod.rs | 8 +- crates/daemon/src/api/server/control.rs | 278 ------------------ crates/daemon/src/api/server/history.rs | 250 ---------------- crates/daemon/src/api/server/mod.rs | 2 - crates/daemon/src/events.rs | 2 +- crates/daemon/src/lib.rs | 161 ----------- crates/daemon/src/main.rs | 167 ++++++++++- crates/daemon/src/server.rs | 17 +- crates/turtle/Cargo.toml | 1 + crates/turtle/build.rs | 32 +++ crates/turtle/proto/control.proto | 33 +++ crates/turtle/proto/history.proto | 78 +++++ crates/turtle/src/client/mod.rs | 267 ++++++++++++++++++ crates/turtle/src/generated.rs | 23 ++ crates/turtle/src/history/builder.rs | 78 +++++ crates/turtle/src/history/mod.rs | 300 ++++++++++++++++++++ crates/turtle/src/history/secrets.rs | 223 +++++++++++++++ crates/turtle/src/lib.rs | 5 + 38 files changed, 1786 insertions(+), 2004 deletions(-) delete mode 100644 crates/daemon/build.rs delete mode 100644 crates/daemon/proto/control.proto delete mode 100644 crates/daemon/proto/history.proto delete mode 100644 crates/daemon/src/aclient/history/builder.rs delete mode 100644 crates/daemon/src/aclient/secrets.rs delete mode 100644 crates/daemon/src/api/client/mod.rs create mode 100644 crates/daemon/src/api/control.rs delete mode 100644 crates/daemon/src/api/generated.rs create mode 100644 crates/daemon/src/api/history.rs delete mode 100644 crates/daemon/src/api/server/control.rs delete mode 100644 crates/daemon/src/api/server/history.rs delete mode 100644 crates/daemon/src/api/server/mod.rs delete mode 100644 crates/daemon/src/lib.rs create mode 100644 crates/turtle/build.rs create mode 100644 crates/turtle/proto/control.proto create mode 100644 crates/turtle/proto/history.proto create mode 100644 crates/turtle/src/client/mod.rs create mode 100644 crates/turtle/src/generated.rs create mode 100644 crates/turtle/src/history/builder.rs create mode 100644 crates/turtle/src/history/mod.rs create mode 100644 crates/turtle/src/history/secrets.rs diff --git a/Cargo.lock b/Cargo.lock index 9115699c..ab3dfded 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -204,6 +204,7 @@ dependencies = [ "tonic-prost-build", "tracing", "tracing-subscriber", + "turtle", "turtle-common", "turtle-daemon", "unicode-segmentation", @@ -4746,6 +4747,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "turtle-common", "typed-builder", "unicode-segmentation", "unicode-width 0.2.2", @@ -4759,78 +4761,14 @@ dependencies = [ name = "turtle-common" version = "18.16.1" dependencies = [ - "arboard", - "async-trait", - "axum", - "base64", - "clap", - "clap_complete", - "clap_complete_nushell", - "colored", - "config", - "crossterm", - "crypto_secretbox", - "daemonize", - "dashmap", "directories", "eyre", - "fs-err", - "fs4", - "fuzzy-matcher", - "hyper-util", - "indicatif", - "interim", - "itertools", - "lasso", - "listenfd", - "log", - "metrics", - "metrics-exporter-prometheus", - "minspan", - "norm", - "notify", - "portable-pty", - "prost", - "protox", - "rand 0.8.5", - "ratatui", - "regex", - "reqwest", - "rmp", - "runtime-format", - "rustix", - "rustls", - "rusty_paserk", - "rusty_paseto", "semver", "serde", - "serde_json", - "serde_regex", - "serde_with", - "shellexpand", - "signal-hook", - "sql-builder", "sqlx", - "thiserror 2.0.18", "time", - "tokio", - "tokio-stream", - "toml_edit", - "tonic", - "tonic-prost", - "tonic-prost-build", - "tower", - "tower-http", - "tracing", - "tracing-appender", - "tracing-subscriber", "typed-builder", - "unicode-segmentation", - "unicode-width 0.2.2", - "url", "uuid", - "vt100", - "whoami 2.1.1", ] [[package]] @@ -4902,6 +4840,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "turtle", "turtle-common", "typed-builder", "unicode-segmentation", @@ -4916,79 +4855,24 @@ dependencies = [ name = "turtle-server" version = "18.16.1" dependencies = [ - "arboard", - "async-trait", "axum", - "base64", "clap", - "clap_complete", - "clap_complete_nushell", - "colored", "config", - "crossterm", - "crypto_secretbox", - "daemonize", - "dashmap", - "directories", "eyre", "fs-err", - "fs4", - "fuzzy-matcher", - "hyper-util", - "indicatif", - "interim", - "itertools", - "lasso", - "listenfd", - "log", "metrics", "metrics-exporter-prometheus", - "minspan", - "norm", - "notify", - "portable-pty", - "prost", - "protox", "rand 0.8.5", - "ratatui", - "regex", - "reqwest", - "rmp", - "runtime-format", - "rustix", - "rustls", - "rusty_paserk", - "rusty_paseto", - "semver", "serde", - "serde_json", - "serde_regex", - "serde_with", - "shellexpand", - "signal-hook", - "sql-builder", "sqlx", - "thiserror 2.0.18", "time", "tokio", - "tokio-stream", - "toml_edit", - "tonic", - "tonic-prost", - "tonic-prost-build", "tower", "tower-http", "tracing", - "tracing-appender", - "tracing-subscriber", "turtle-common", - "typed-builder", - "unicode-segmentation", - "unicode-width 0.2.2", "url", "uuid", - "vt100", - "whoami 2.1.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 534120ca..bd955897 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,8 @@ readme = "README.md" [workspace.dependencies] turtle-common = { path = "crates/common" } turtle-daemon = { path = "crates/daemon" } +turtle = { path = "crates/turtle" } +clap = { version = "4.5.7", features = ["derive"] } [profile.profiling] inherits = "release" diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index fd5ea57a..058f2f3a 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -14,6 +14,7 @@ repository = { workspace = true } [dependencies] turtle-daemon = {workspace = true} turtle-common = {workspace = true} +turtle = { workspace = true } clap = { version = "4.5.7", features = ["derive"] } clap_complete = "4.5.8" clap_complete_nushell = "4.5.4" diff --git a/crates/client/src/command/client/history/start.rs b/crates/client/src/command/client/history/start.rs index 81aaa904..defd46a3 100644 --- a/crates/client/src/command/client/history/start.rs +++ b/crates/client/src/command/client/history/start.rs @@ -6,9 +6,10 @@ use crate::{ use eyre::{Result, eyre}; use time::OffsetDateTime; use tracing::debug; +use turtle::History; use turtle_common::utils; use turtle_daemon::{ - aclient::history::{History, SettingsFilter}, + aclient::history::SettingsFilter, api::client::{HistoryClient, daemon_matches_expected, daemon_mismatch_message}, }; @@ -23,7 +24,7 @@ pub(super) async fn handle( let cwd = utils::get_current_dir(); let command = normalize_command_for_storage(command, settings); - let mut h: History = History::capture() + let mut h: History = History::daemon() .timestamp(OffsetDateTime::now_utc()) .command(command) .cwd(cwd) diff --git a/crates/daemon/Cargo.toml b/crates/daemon/Cargo.toml index 07fd84d7..830dbd12 100644 --- a/crates/daemon/Cargo.toml +++ b/crates/daemon/Cargo.toml @@ -13,6 +13,7 @@ repository = { workspace = true } [dependencies] turtle-common = { workspace = true } +turtle = { workspace = true } async-trait = "0.1.58" axum = "0.8" base64 = "0.22" diff --git a/crates/daemon/build.rs b/crates/daemon/build.rs deleted file mode 100644 index 62612968..00000000 --- a/crates/daemon/build.rs +++ /dev/null @@ -1,32 +0,0 @@ -use std::{env, fs, path::PathBuf}; - -use protox::Compiler; -use protox::prost::Message; - -fn main() -> Result<(), std::io::Error> { - let proto_paths = [ - "proto/history.proto", - "proto/control.proto", - ]; - let proto_include_dirs = ["proto"]; - - let file_descriptor_set = Compiler::new(proto_include_dirs) - .map_err(std::io::Error::other)? - .include_source_info(true) - .include_imports(true) - .open_files(proto_paths) - .map_err(std::io::Error::other)? - .file_descriptor_set(); - - let file_descriptor_path = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR not set")) - .join("file_descriptor_set.bin"); - fs::write(&file_descriptor_path, file_descriptor_set.encode_to_vec()).unwrap(); - - tonic_prost_build::configure() - .build_server(true) - .file_descriptor_set_path(&file_descriptor_path) - .skip_protoc_run() - .compile_protos(&proto_paths, &proto_include_dirs)?; - - Ok(()) -} diff --git a/crates/daemon/proto/control.proto b/crates/daemon/proto/control.proto deleted file mode 100644 index a8026cb8..00000000 --- a/crates/daemon/proto/control.proto +++ /dev/null @@ -1,33 +0,0 @@ -syntax = "proto3"; -package control; - -service Control { - // Tell the daemon to perform a sync operation. - rpc ForceSync(ForceSyncRequest) returns (ForceSyncReply); - - // Query the daemon for it's status. - rpc Status(StatusRequest) returns (StatusReply); - - // Query the daemon about used paths. - rpc Paths(PathsRequest) returns (PathsReply); -} - -message ForceSyncRequest {} -message ForceSyncReply { - bool accepted = 1; -} - -message StatusRequest {} -message StatusReply { - bool healthy = 1; - string version = 2; - uint32 pid = 3; - uint32 protocol = 4; -} - -message PathsRequest {} -message PathsReply { - string config = 1; - string db = 2; - string socket = 3; -} diff --git a/crates/daemon/proto/history.proto b/crates/daemon/proto/history.proto deleted file mode 100644 index 850b16b9..00000000 --- a/crates/daemon/proto/history.proto +++ /dev/null @@ -1,78 +0,0 @@ -syntax = "proto3"; -package history; - -service History { - rpc StartHistory(StartHistoryRequest) returns (StartHistoryReply); - rpc EndHistory(EndHistoryRequest) returns (EndHistoryReply); - - rpc TailHistory(TailHistoryRequest) returns (stream TailHistoryReply); - - // Request history from the daemon - rpc History(HistoryRequest) returns (HistoryReply); -} - -message StartHistoryRequest { - uint64 timestamp = 1; // nanosecond unix epoch - string command = 2; - string cwd = 3; - string session = 4; - string hostname = 5; - string author = 6; - string intent = 7; -} -message StartHistoryReply { - string id = 1; - string version = 2; - uint32 protocol = 3; -} - -message EndHistoryRequest { - string id = 1; - int64 exit = 2; - uint64 duration = 3; -} -message EndHistoryReply { - string id = 1; - uint64 idx = 2; - string version = 3; - uint32 protocol = 4; -} - - -message TailHistoryRequest {} -message TailHistoryReply { - HistoryEventKind kind = 1; - HistoryEntry history = 2; -} - -enum HistoryEventKind { - HISTORY_EVENT_KIND_UNSPECIFIED = 0; - HISTORY_EVENT_KIND_STARTED = 1; - HISTORY_EVENT_KIND_ENDED = 2; -} - -message HistoryEntry { - uint64 timestamp = 1; // nanosecond unix epoch - string id = 2; - string command = 3; - string cwd = 4; - string session = 5; - string hostname = 6; - string author = 7; - string intent = 8; - int64 exit = 9; - int64 duration = 10; -} - -message Range { - uint64 start = 1; - uint64 end = 2; -} - -message HistoryRequest { - string session = 1; - optional Range range = 2; -} -message HistoryReply { - repeated HistoryEntry entries = 1; -} diff --git a/crates/daemon/src/aclient/database/mod.rs b/crates/daemon/src/aclient/database/mod.rs index 5287807c..cdf71065 100644 --- a/crates/daemon/src/aclient/database/mod.rs +++ b/crates/daemon/src/aclient/database/mod.rs @@ -3,7 +3,6 @@ use std::{ str::FromStr, }; -use crate::aclient::utils::setup_db; use fs_err::{self as fs}; use itertools::Itertools; use sql_builder::{SqlBuilder, SqlName, bind::Bind, esc, quote}; @@ -13,19 +12,16 @@ use sqlx::{ }; use time::OffsetDateTime; use tracing::debug; +use turtle::history::{History, HistoryId, get_host_user}; use turtle_common::utils; use uuid::Uuid; use crate::aclient::{ - history::{HistoryId, HistoryStats}, - utils::get_host_user, -}; - -use super::{ - history::History, + history::HistoryStats, ordering, - settings::{FilterMode, SearchMode, Settings}, + settings::{FilterMode, SearchMode}, }; +use crate::aclient::{settings::Settings, utils::setup_db}; #[derive(Clone)] pub(crate) struct Context { @@ -858,10 +854,15 @@ mod test { } async fn new_history_item(db: &mut ClientSqlite, cmd: &str) -> Result<()> { - let mut captured: History = History::capture() + const SESSION: &str = "test"; + const HOSTNAME: &str = "test.host"; + + let mut captured: History = History::daemon() .timestamp(OffsetDateTime::now_utc()) .command(cmd) .cwd("/home/ellie") + .session(SESSION) + .hostname(HOSTNAME) .build() .into(); diff --git a/crates/daemon/src/aclient/history/builder.rs b/crates/daemon/src/aclient/history/builder.rs deleted file mode 100644 index ef52637b..00000000 --- a/crates/daemon/src/aclient/history/builder.rs +++ /dev/null @@ -1,154 +0,0 @@ -use typed_builder::TypedBuilder; - -use super::History; - -/// Builder for a history entry that is imported from shell history. -/// -/// The only two required fields are `timestamp` and `command`. -#[derive(Debug, Clone, TypedBuilder)] -pub(crate) struct HistoryImported { - timestamp: time::OffsetDateTime, - #[builder(setter(into))] - command: String, - #[builder(default = "unknown".into(), setter(into))] - cwd: String, - #[builder(default = -1)] - exit: i64, - #[builder(default = -1)] - duration: i64, - #[builder(default, setter(strip_option, into))] - session: Option, - #[builder(default, setter(strip_option, into))] - hostname: Option, - #[builder(default, setter(strip_option, into))] - author: Option, - #[builder(default, setter(strip_option, into))] - intent: Option, -} - -impl From for History { - fn from(imported: HistoryImported) -> Self { - Self::new( - imported.timestamp, - imported.command, - imported.cwd, - imported.exit, - imported.duration, - imported.session, - imported.hostname, - imported.author, - imported.intent, - None, - ) - } -} - -/// Builder for a history entry that is captured via hook. -/// -/// This builder is used only at the `start` step of the hook, -/// so it doesn't have any fields which are known only after -/// the command is finished, such as `exit` or `duration`. -#[derive(Debug, Clone, TypedBuilder)] -pub struct HistoryCaptured { - timestamp: time::OffsetDateTime, - #[builder(setter(into))] - command: String, - #[builder(setter(into))] - cwd: String, - #[builder(default, setter(strip_option, into))] - author: Option, - #[builder(default, setter(strip_option, into))] - intent: Option, -} - -impl From for History { - fn from(captured: HistoryCaptured) -> Self { - Self::new( - captured.timestamp, - captured.command, - captured.cwd, - -1, - -1, - None, - None, - captured.author, - captured.intent, - None, - ) - } -} - -/// Builder for a history entry that is loaded from the database. -/// -/// All fields are required, as they are all present in the database. -#[derive(Debug, Clone, TypedBuilder)] -pub(crate) struct HistoryFromDb { - id: String, - timestamp: time::OffsetDateTime, - command: String, - cwd: String, - exit: i64, - duration: i64, - session: String, - hostname: String, - author: String, - intent: Option, - deleted_at: Option, -} - -impl From for History { - fn from(from_db: HistoryFromDb) -> Self { - Self { - id: from_db.id.into(), - timestamp: from_db.timestamp, - exit: from_db.exit, - command: from_db.command, - cwd: from_db.cwd, - duration: from_db.duration, - session: from_db.session, - hostname: from_db.hostname, - author: from_db.author, - intent: from_db.intent, - deleted_at: from_db.deleted_at, - } - } -} - -/// Builder for a history entry that is captured via hook and sent to the daemon -/// -/// This builder is similar to Capture, but we just require more information up front. -/// For the old setup, we could just rely on `History::new` to read some of the missing -/// data. This is no longer the case. -#[derive(Debug, Clone, TypedBuilder)] -pub(crate) struct HistoryDaemonCapture { - timestamp: time::OffsetDateTime, - #[builder(setter(into))] - command: String, - #[builder(setter(into))] - cwd: String, - #[builder(setter(into))] - session: String, - #[builder(setter(into))] - hostname: String, - #[builder(default, setter(strip_option, into))] - author: Option, - #[builder(default, setter(strip_option, into))] - intent: Option, -} - -impl From for History { - fn from(captured: HistoryDaemonCapture) -> Self { - Self::new( - captured.timestamp, - captured.command, - captured.cwd, - -1, - -1, - Some(captured.session), - Some(captured.hostname), - captured.author, - captured.intent, - None, - ) - } -} diff --git a/crates/daemon/src/aclient/history/mod.rs b/crates/daemon/src/aclient/history/mod.rs index 527d1bb6..ea71dcf4 100644 --- a/crates/daemon/src/aclient/history/mod.rs +++ b/crates/daemon/src/aclient/history/mod.rs @@ -5,17 +5,15 @@ use rmp::decode::ValueReadError; use rmp::{Marker, decode::Bytes}; use std::env; use std::fmt::Display; +use turtle::history::History; use turtle_common::record::DecryptedData; use turtle_common::utils::uuid_v7; use eyre::{Result, bail, eyre}; -use crate::aclient::secrets::SECRET_PATTERNS_RE; -use crate::aclient::utils::get_host_user; use time::OffsetDateTime; -mod builder; pub(crate) mod store; pub(crate) const HISTORY_VERSION_V0: &str = "v0"; @@ -27,72 +25,6 @@ pub(crate) const HISTORY_TAG: &str = "history"; const HISTORY_AUTHOR_ENV: &str = "ATUIN_HISTORY_AUTHOR"; const HISTORY_INTENT_ENV: &str = "ATUIN_HISTORY_INTENT"; -#[derive(Clone, Debug, Eq, PartialEq, Hash)] -pub struct HistoryId(pub String); - -impl Display for HistoryId { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl From for HistoryId { - fn from(s: String) -> Self { - Self(s) - } -} - -/// Client-side history entry. -/// -/// Client stores data unencrypted, and only encrypts it before sending to the server. -/// -/// To create a new history entry, use one of the builders: -/// - [`History::import()`] to import an entry from the shell history file -/// - [`History::capture()`] to capture an entry via hook -/// - [`History::from_db()`] to create an instance from the database entry -// -// ## Implementation Notes -// -// New fields must be added to `History::{serialize,deserialize}` in a backwards -// compatible way (sensible defaults and careful `nfields` handling). -#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)] -pub struct History { - /// A client-generated ID, used to identify the entry when syncing. - /// - /// Stored as `client_id` in the database. - pub id: HistoryId, - - /// When the command was run. - pub timestamp: OffsetDateTime, - - /// How long the command took to run. - pub duration: i64, - - /// The exit code of the command. - pub exit: i64, - - /// The command that was run. - pub command: String, - - /// The current working directory when the command was run. - pub cwd: String, - - /// The session ID, associated with a terminal session. - pub session: String, - - /// The hostname of the machine the command was run on. - pub hostname: String, - - /// Who wrote this command (human user or automation/agent identity). - pub author: String, - - /// Optional rationale for why the command was executed. - pub intent: Option, - - /// Timestamp, which is set when the entry is deleted, allowing a soft delete. - pub deleted_at: Option, -} - #[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)] pub(crate) struct HistoryStats { /// The command that was ran after this one in the session @@ -113,63 +45,17 @@ pub(crate) struct HistoryStats { pub(crate) duration_over_time: Vec<(String, i64)>, } -impl History { - pub(crate) fn author_from_hostname(hostname: &str) -> String { - hostname - .split_once(':') - .map_or_else(|| hostname.to_owned(), |(_, user)| user.to_owned()) - } - - fn normalize_optional_field(field: Option) -> Option { - field.and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_owned()) - } - }) - } - - #[expect(clippy::too_many_arguments)] - fn new( - timestamp: OffsetDateTime, - command: String, - cwd: String, - exit: i64, - duration: i64, - session: Option, - hostname: Option, - author: Option, - intent: Option, - deleted_at: Option, - ) -> Self { - let session = session - .or_else(|| env::var("ATUIN_SESSION").ok()) - .unwrap_or_else(|| uuid_v7().as_simple().to_string()); - let hostname = hostname.unwrap_or_else(get_host_user); - let author = Self::normalize_optional_field(author) - .or_else(|| Self::normalize_optional_field(env::var(HISTORY_AUTHOR_ENV).ok())) - .unwrap_or_else(|| Self::author_from_hostname(hostname.as_str())); - let intent = Self::normalize_optional_field(intent) - .or_else(|| Self::normalize_optional_field(env::var(HISTORY_INTENT_ENV).ok())); - - Self { - id: uuid_v7().as_simple().to_string().into(), - timestamp, - command, - cwd, - exit, - duration, - session, - hostname, - author, - intent, - deleted_at, - } - } +pub(crate) trait HistoryExt: Sized { + fn serialize(&self) -> Result; + fn read_optional_string(bytes: &[u8]) -> Result<(Option, &[u8])>; + fn deserialize_v0(bytes: &[u8]) -> Result; + fn deserialize_v1(bytes: &[u8]) -> Result; + fn deserialize(bytes: &[u8], version: &str) -> Result; + fn success(&self) -> bool; +} - pub(crate) fn serialize(&self) -> Result { +impl HistoryExt for History { + fn serialize(&self) -> Result { // This is pretty much the same as what we used for the old history, with one difference - // it uses integers for timestamps rather than a string format. @@ -358,7 +244,7 @@ impl History { }) } - pub(crate) fn deserialize(bytes: &[u8], version: &str) -> Result { + fn deserialize(bytes: &[u8], version: &str) -> Result { match version { HISTORY_VERSION_V0 => Self::deserialize_v0(bytes), HISTORY_VERSION_V1 => Self::deserialize_v1(bytes), @@ -367,118 +253,10 @@ impl History { } } - /// Builder for a history entry that is captured via hook. - /// - /// This builder is used only at the `start` step of the hook, - /// so it doesn't have any fields which are known only after - /// the command is finished, such as `exit` or `duration`. - /// - /// ## Examples - /// ```rust - /// use crate::aclient::history::History; - /// - /// let history: History = History::capture() - /// .timestamp(time::OffsetDateTime::now_utc()) - /// .command("ls -la") - /// .cwd("/home/user") - /// .build() - /// .into(); - /// ``` - /// - /// Command without any required info cannot be captured, which is forced at compile time: - /// - /// ```compile_fail - /// use crate::aclient::history::History; - /// - /// // this will not compile because `cwd` is missing - /// let history: History = History::capture() - /// .timestamp(time::OffsetDateTime::now_utc()) - /// .command("ls -la") - /// .build() - /// .into(); - /// ``` - pub fn capture() -> builder::HistoryCapturedBuilder { - builder::HistoryCaptured::builder() - } - - /// Builder for a history entry that is captured via hook, and sent to the daemon. - /// - /// This builder is used only at the `start` step of the hook, - /// so it doesn't have any fields which are known only after - /// the command is finished, such as `exit` or `duration`. - /// - /// It does, however, include information that can usually be inferred. - /// - /// This is because the daemon we are sending a request to lacks the context of the command - /// - /// ## Examples - /// ```rust - /// use crate::aclient::history::History; - /// - /// let history: History = History::daemon() - /// .timestamp(time::OffsetDateTime::now_utc()) - /// .command("ls -la") - /// .cwd("/home/user") - /// .session("018deb6e8287781f9973ef40e0fde76b") - /// .hostname("computer:ellie") - /// .build() - /// .into(); - /// ``` - /// - /// Command without any required info cannot be captured, which is forced at compile time: - /// - /// ```compile_fail - /// use crate::aclient::history::History; - /// - /// // this will not compile because `hostname` is missing - /// let history: History = History::daemon() - /// .timestamp(time::OffsetDateTime::now_utc()) - /// .command("ls -la") - /// .cwd("/home/user") - /// .session("018deb6e8287781f9973ef40e0fde76b") - /// .build() - /// .into(); - /// ``` - pub(crate) fn daemon() -> builder::HistoryDaemonCaptureBuilder { - builder::HistoryDaemonCapture::builder() - } - - /// Builder for a history entry that is imported from the database. - /// - /// All fields are required, as they are all present in the database. - /// - /// ```compile_fail - /// use crate::aclient::history::History; - /// - /// // this will not compile because `id` field is missing - /// let history: History = History::from_db() - /// .timestamp(time::OffsetDateTime::now_utc()) - /// .command("ls -la".to_string()) - /// .cwd("/home/user".to_string()) - /// .exit(0) - /// .duration(100) - /// .session("somesession".to_string()) - /// .hostname("localhost".to_string()) - /// .author("user".to_string()) - /// .intent(None) - /// .deleted_at(None) - /// .build() - /// .into(); - /// ``` - pub(crate) fn from_db() -> builder::HistoryFromDbBuilder { - builder::HistoryFromDb::builder() - } - - pub(crate) fn success(&self) -> bool { + #[expect(unused)] + fn success(&self) -> bool { self.exit == 0 || self.duration == -1 } - - pub fn should_save(&self, filter: SettingsFilter<'_>) -> bool { - !(self.command.is_empty() - || filter.history.is_match(&self.command) - || filter.cwd.is_match(&self.cwd) - || (filter.secrets && SECRET_PATTERNS_RE.is_match(&self.command))) - } } #[derive(Debug, Copy, Clone)] @@ -490,89 +268,12 @@ pub struct SettingsFilter<'a> { #[cfg(test)] mod tests { - use regex::RegexSet; use time::macros::datetime; - use crate::aclient::{history::HISTORY_VERSION, settings::Settings}; + use crate::aclient::history::{HISTORY_VERSION, HistoryExt}; use super::History; - // Test that we don't save history where necessary - #[test] - fn privacy_test() { - let settings = Settings { - cwd_filter: RegexSet::new(["^/supasecret"]).unwrap(), - history_filter: RegexSet::new(["^psql"]).unwrap(), - ..Settings::default() - }; - - let normal_command: History = History::capture() - .timestamp(time::OffsetDateTime::now_utc()) - .command("echo foo") - .cwd("/") - .build() - .into(); - - let with_space: History = History::capture() - .timestamp(time::OffsetDateTime::now_utc()) - .command(" echo bar") - .cwd("/") - .build() - .into(); - - let empty: History = History::capture() - .timestamp(time::OffsetDateTime::now_utc()) - .command("") - .cwd("/") - .build() - .into(); - - let stripe_key: History = History::capture() - .timestamp(time::OffsetDateTime::now_utc()) - .command("curl foo.com/bar?key=sk_test_1234567890abcdefghijklmnop") - .cwd("/") - .build() - .into(); - - let secret_dir: History = History::capture() - .timestamp(time::OffsetDateTime::now_utc()) - .command("echo ohno") - .cwd("/supasecret") - .build() - .into(); - - let with_psql: History = History::capture() - .timestamp(time::OffsetDateTime::now_utc()) - .command("psql") - .cwd("/supasecret") - .build() - .into(); - - assert!(normal_command.should_save(&settings)); - assert!(!with_space.should_save(&settings)); - assert!(!empty.should_save(&settings)); - assert!(!stripe_key.should_save(&settings)); - assert!(!secret_dir.should_save(&settings)); - assert!(!with_psql.should_save(&settings)); - } - - #[test] - fn disable_secrets() { - let settings = Settings { - secrets_filter: false, - ..Settings::new().unwrap() - }; - - let stripe_key: History = History::capture() - .timestamp(time::OffsetDateTime::now_utc()) - .command("curl foo.com/bar?key=sk_test_1234567890abcdefghijklmnop") - .cwd("/") - .build() - .into(); - - assert!(stripe_key.should_save(&settings)); - } - #[test] fn test_serialize_deserialize() { let history = History { diff --git a/crates/daemon/src/aclient/history/store.rs b/crates/daemon/src/aclient/history/store.rs index 216f85d6..c62f9068 100644 --- a/crates/daemon/src/aclient/history/store.rs +++ b/crates/daemon/src/aclient/history/store.rs @@ -2,14 +2,16 @@ use std::collections::HashSet; use eyre::{Result, bail, eyre}; use rmp::decode::Bytes; +use turtle::history::{History, HistoryId}; use crate::aclient::{ database::ClientSqlite, + history::HistoryExt, record::{encryption::PASETO_V4, sqlite_store::SqliteStore}, }; use turtle_common::record::{DecryptedData, Host, HostId, Record, RecordId, RecordIdx}; -use super::{HISTORY_TAG, HISTORY_VERSION, HISTORY_VERSION_V0, History, HistoryId}; +use super::{HISTORY_TAG, HISTORY_VERSION, HISTORY_VERSION_V0}; #[derive(Debug, Clone)] pub(crate) struct HistoryStore { diff --git a/crates/daemon/src/aclient/mod.rs b/crates/daemon/src/aclient/mod.rs index 3f3709a9..f2d14e01 100644 --- a/crates/daemon/src/aclient/mod.rs +++ b/crates/daemon/src/aclient/mod.rs @@ -1,11 +1,10 @@ -pub mod database; +pub(crate) mod database; pub mod history; -pub mod record; -pub mod settings; +pub(crate) mod record; +pub(crate) mod settings; +pub(crate) mod api_client; pub(crate) mod encryption; pub(crate) mod meta; -pub(crate) mod utils; pub(crate) mod ordering; -pub(crate) mod secrets; -pub(crate) mod api_client; +pub(crate) mod utils; diff --git a/crates/daemon/src/aclient/ordering.rs b/crates/daemon/src/aclient/ordering.rs index 84001f52..8fa6498e 100644 --- a/crates/daemon/src/aclient/ordering.rs +++ b/crates/daemon/src/aclient/ordering.rs @@ -1,6 +1,7 @@ use minspan::minspan; +use turtle::history::History; -use super::{history::History, settings::SearchMode}; +use super::settings::SearchMode; pub(crate) fn reorder_fuzzy(mode: SearchMode, query: &str, res: Vec) -> Vec { match mode { diff --git a/crates/daemon/src/aclient/secrets.rs b/crates/daemon/src/aclient/secrets.rs deleted file mode 100644 index 08d24339..00000000 --- a/crates/daemon/src/aclient/secrets.rs +++ /dev/null @@ -1,223 +0,0 @@ -// This file will probably trigger a lot of scanners. Sorry. - -use regex::RegexSet; -use std::sync::LazyLock; - -#[cfg(test)] -pub(crate) enum TestValue<'a> { - Single(&'a str), - Multiple(&'a [&'a str]), -} - -#[cfg(test)] -type SpType<'a> = &'a [(&'a str, &'a str, TestValue<'a>)]; - -#[cfg(not(test))] -type SpType<'a> = &'a [(&'a str, &'a str)]; - -/// A list of `(name, regex, test)`, where `test` should match against `regex`. -pub(crate) static SECRET_PATTERNS: SpType<'_> = &[ - ( - "AWS Access Key ID", - "A[KS]IA[0-9A-Z]{16}", - #[cfg(test)] - TestValue::Single("AKIAIOSFODNN7EXAMPLE"), - ), - ( - "AWS Secret Access Key env var", - "AWS_SECRET_ACCESS_KEY", - #[cfg(test)] - TestValue::Single("AWS_SECRET_ACCESS_KEY=KEYDATA"), - ), - ( - "AWS Session Token env var", - "AWS_SESSION_TOKEN", - #[cfg(test)] - TestValue::Single("AWS_SESSION_TOKEN=KEYDATA"), - ), - ( - "Microsoft Azure secret access key env var", - "AZURE_.*_KEY", - #[cfg(test)] - TestValue::Single("export AZURE_STORAGE_ACCOUNT_KEY=KEYDATA"), - ), - ( - "Google cloud platform key env var", - "GOOGLE_SERVICE_ACCOUNT_KEY", - #[cfg(test)] - TestValue::Single("export GOOGLE_SERVICE_ACCOUNT_KEY=KEYDATA"), - ), - ( - "Atuin login", - r"atuin\s+login", - #[cfg(test)] - TestValue::Single( - "atuin login -u mycoolusername -p mycoolpassword -k \"lots of random words\"", - ), - ), - ( - "GitHub PAT (old)", - "ghp_[a-zA-Z0-9]{36}", - #[cfg(test)] - TestValue::Single("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"), // legit, I expired it - ), - ( - "GitHub PAT (new)", - "gh1_[A-Za-z0-9]{21}_[A-Za-z0-9]{59}|github_pat_[0-9][A-Za-z0-9]{21}_[A-Za-z0-9]{59}", - #[cfg(test)] - TestValue::Multiple(&[ - "gh1_1234567890abcdefghijk_1234567890abcdefghijklmnopqrstuvwxyz1234567890abcdefghijklm", - "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", // also legit, also expired - ]), - ), - ( - "GitHub OAuth Access Token", - "gho_[A-Za-z0-9]{36}", - #[cfg(test)] - TestValue::Single("gho_1234567890abcdefghijklmnopqrstuvwx000"), // not a real token - ), - ( - "GitHub OAuth Access Token (user)", - "ghu_[A-Za-z0-9]{36}", - #[cfg(test)] - TestValue::Single("ghu_1234567890abcdefghijklmnopqrstuvwx000"), // not a real token - ), - ( - "GitHub App Installation Access Token", - "ghs_[A-Za-z0-9._-]{36,}", - #[cfg(test)] - TestValue::Multiple(&[ - "ghs_1234567890abcdefghijklmnopqrstuvwx000", // not a real token - "ghs_abc-def.ghi_jklMNOP0123456789qrstuv-wxyzABCD", // new token format, fake data - ]), - ), - ( - "GitHub Refresh Token", - "ghr_[A-Za-z0-9]{76}", - #[cfg(test)] - TestValue::Single( - "ghr_1234567890abcdefghijklmnopqrstuvwx1234567890abcdefghijklmnopqrstuvwx1234567890abcdefghijklmnopqrstuvwx", - ), // not a real token - ), - ( - "GitHub App Installation Access Token v1", - "v1\\.[0-9A-Fa-f]{40}", - #[cfg(test)] - TestValue::Single("v1.1234567890abcdef1234567890abcdef12345678"), // not a real token - ), - ( - "GitLab PAT", - "glpat-[a-zA-Z0-9_]{20}", - #[cfg(test)] - TestValue::Single("glpat-RkE_BG5p_bbjML21WSfy"), - ), - ( - "Slack OAuth v2 bot", - "xoxb-[0-9]{11}-[0-9]{11}-[0-9a-zA-Z]{24}", - #[cfg(test)] - TestValue::Single("xoxb-17653672481-19874698323-pdFZKVeTuE8sk7oOcBrzbqgy"), - ), - ( - "Slack OAuth v2 user token", - "xoxp-[0-9]{11}-[0-9]{11}-[0-9a-zA-Z]{24}", - #[cfg(test)] - TestValue::Single("xoxp-17653672481-19874698323-pdFZKVeTuE8sk7oOcBrzbqgy"), - ), - ( - "Slack webhook", - "T[a-zA-Z0-9_]{8}/B[a-zA-Z0-9_]{8}/[a-zA-Z0-9_]{24}", - #[cfg(test)] - TestValue::Single( - "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX", - ), - ), - ( - "Stripe test key", - "sk_test_[0-9a-zA-Z]{24}", - #[cfg(test)] - TestValue::Single("sk_test_1234567890abcdefghijklmnop"), - ), - ( - "Stripe live key", - "sk_live_[0-9a-zA-Z]{24}", - #[cfg(test)] - TestValue::Single("sk_live_1234567890abcdefghijklmnop"), - ), - ( - "Netlify authentication token", - "nf[pcoub]_[0-9a-zA-Z]{36}", - #[cfg(test)] - TestValue::Single("nfp_nBh7BdJxUwyaBBwFzpyD29MMFT6pZ9wq5634"), - ), - ( - "npm token", - "npm_[A-Za-z0-9]{36}", - #[cfg(test)] - TestValue::Single("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"), - ), - ( - "Pulumi personal access token", - "pul-[0-9a-f]{40}", - #[cfg(test)] - TestValue::Single("pul-683c2770662c51d960d72ec27613be7653c5cb26"), - ), -]; - -/// The `regex` expressions from [`SECRET_PATTERNS`] compiled into a `RegexSet`. -pub(crate) static SECRET_PATTERNS_RE: LazyLock = LazyLock::new(|| { - let exprs = SECRET_PATTERNS.iter().map(|f| f.1); - RegexSet::new(exprs).expect("Failed to build secrets regex") -}); - -#[cfg(test)] -mod tests { - use regex::Regex; - - use crate::aclient::secrets::{SECRET_PATTERNS, TestValue}; - - #[test] - fn test_secrets() { - for (name, regex, test) in SECRET_PATTERNS { - let re = - Regex::new(regex).unwrap_or_else(|_| panic!("Failed to compile regex for {name}")); - - match test { - TestValue::Single(test) => { - assert!(re.is_match(test), "{name} test failed!"); - } - TestValue::Multiple(tests) => { - for test_str in tests.iter() { - assert!( - re.is_match(test_str), - "{name} test with value \"{test_str}\" failed!" - ); - } - } - } - } - } - - #[test] - fn test_secrets_embedded() { - for (name, regex, test) in SECRET_PATTERNS { - let re = - Regex::new(regex).unwrap_or_else(|_| panic!("Failed to compile regex for {name}")); - - match test { - TestValue::Single(test) => { - let embedded = format!("some random text {test} some more random text"); - assert!(re.is_match(&embedded), "{name} embedded test failed!"); - } - TestValue::Multiple(tests) => { - for test_str in tests.iter() { - let embedded = format!("some random text {test_str} some more random text"); - assert!( - re.is_match(&embedded), - "{name} embedded test with value \"{test_str}\" failed!" - ); - } - } - } - } - } -} diff --git a/crates/daemon/src/aclient/utils.rs b/crates/daemon/src/aclient/utils.rs index cf515183..18e732a0 100644 --- a/crates/daemon/src/aclient/utils.rs +++ b/crates/daemon/src/aclient/utils.rs @@ -1,18 +1,3 @@ -pub(crate) fn get_hostname() -> String { - std::env::var("ATUIN_HOST_NAME") - .unwrap_or_else(|_| whoami::hostname().unwrap_or_else(|_| "unknown-host".to_string())) -} - -pub(crate) fn get_username() -> String { - std::env::var("ATUIN_HOST_USER") - .unwrap_or_else(|_| whoami::username().unwrap_or_else(|_| "unknown-user".to_string())) -} - -/// Returns a pair of the hostname and username, separated by a colon. -pub(crate) fn get_host_user() -> String { - format!("{}:{}", get_hostname(), get_username()) -} - /// Setup a [`SQLite`] database. /// /// This takes care of correct locking, so that we avoid a race when setting up the database. diff --git a/crates/daemon/src/api/client/mod.rs b/crates/daemon/src/api/client/mod.rs deleted file mode 100644 index c588fb09..00000000 --- a/crates/daemon/src/api/client/mod.rs +++ /dev/null @@ -1,272 +0,0 @@ -use eyre::{Context as EyreContext, Result}; -use time::OffsetDateTime; -use tonic::Code; -use tonic::transport::{Channel, Endpoint, Uri}; -use tower::service_fn; - -use hyper_util::rt::TokioIo; - -#[cfg(unix)] -use tokio::net::UnixStream; - -use crate::api::generated; -use crate::api::generated::control::{ForceSyncReply, ForceSyncRequest, PathsReply, PathsRequest}; -use crate::api::generated::history::{HistoryEntry, HistoryRequest}; -use crate::{ - aclient::history::History, - api::{ - DAEMON_PROTOCOL_VERSION, DAEMON_VERSION, - generated::{ - control::{ - StatusReply, StatusRequest, control_client::ControlClient as ControlServiceClient, - }, - history::{ - EndHistoryReply, EndHistoryRequest, StartHistoryReply, StartHistoryRequest, - TailHistoryRequest, history_client::HistoryClient as HistoryServiceClient, - }, - }, - }, -}; - -pub use crate::api::generated::history::{HistoryEventKind, TailHistoryReply}; - -fn normalize_optional_field(value: &str) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_owned()) - } -} - -pub fn history_entry_to_history(entry: HistoryEntry) -> History { - let timestamp = OffsetDateTime::from_unix_timestamp_nanos(i128::from(entry.timestamp)) - .expect("Daemon history timestamp should always be valid"); - - History { - id: entry.id.into(), - timestamp, - duration: entry.duration, - exit: entry.exit, - command: entry.command, - cwd: entry.cwd, - session: entry.session, - hostname: entry.hostname, - author: entry.author, - intent: normalize_optional_field(&entry.intent), - deleted_at: None, - } -} - -#[must_use] -pub fn daemon_matches_expected(version: &str, protocol: u32) -> bool { - version == DAEMON_VERSION && protocol == DAEMON_PROTOCOL_VERSION -} - -#[must_use] -pub fn daemon_mismatch_message(version: &str, protocol: u32) -> String { - if protocol == DAEMON_PROTOCOL_VERSION { - format!("daemon is out of date: expected {DAEMON_VERSION}, got {version}") - } else { - format!("daemon protocol mismatch: expected {DAEMON_PROTOCOL_VERSION}, got {protocol}") - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum DaemonClientErrorKind { - Connect, - Unavailable, - Unimplemented, - Other, -} - -#[must_use] -pub fn classify_error(error: &eyre::Report) -> DaemonClientErrorKind { - for cause in error.chain() { - if cause.downcast_ref::().is_some() { - return DaemonClientErrorKind::Connect; - } - - if let Some(status) = cause.downcast_ref::() { - return match status.code() { - Code::Unavailable => DaemonClientErrorKind::Unavailable, - Code::Unimplemented => DaemonClientErrorKind::Unimplemented, - _ => DaemonClientErrorKind::Other, - }; - } - } - - DaemonClientErrorKind::Other -} - -#[derive(Debug)] -pub enum Probe { - Ready(ControlClient), - NeedsRestart(String), - Unreachable(eyre::Report), -} - -/// Check if a client can reach the daemon. -pub async fn probe(path: String) -> Probe { - let mut client = match ControlClient::new(path).await { - Ok(client) => client, - Err(err) => return Probe::Unreachable(err), - }; - - match client.status().await { - Ok(status) => { - if daemon_matches_expected(&status.version, status.protocol) { - Probe::Ready(client) - } else { - Probe::NeedsRestart(daemon_mismatch_message(&status.version, status.protocol)) - } - } - Err(err) => Probe::Unreachable(err), - } -} - -// ============================================================================ -// History Client -// ============================================================================ - -#[derive(Debug)] -pub struct HistoryClient { - client: HistoryServiceClient, -} - -pub struct Range { - pub start: OffsetDateTime, - pub end: OffsetDateTime, -} - -// Wrap the grpc client -impl HistoryClient { - #[cfg(unix)] - pub async fn new(path: String) -> Result { - use eyre::Context; - - let log_path = path.clone(); - let channel = Endpoint::try_from("http://atuin_local_daemon:0")? - .connect_with_connector(service_fn(move |_: Uri| { - let path = path.clone(); - - async move { - Ok::<_, std::io::Error>(TokioIo::new(UnixStream::connect(path.clone()).await?)) - } - })) - .await - .wrap_err_with(|| { - format!( - "failed to connect to local atuin daemon at {}. Is it running?", - &log_path - ) - })?; - - let client = HistoryServiceClient::new(channel); - - Ok(Self { client }) - } - - pub async fn start_history(&mut self, h: History) -> Result { - let req = StartHistoryRequest { - command: h.command, - cwd: h.cwd, - hostname: h.hostname, - session: h.session, - timestamp: h.timestamp.unix_timestamp_nanos() as u64, - author: h.author, - intent: h.intent.unwrap_or_default(), - }; - - Ok(self.client.start_history(req).await?.into_inner()) - } - - pub async fn history(&mut self, session: String, range: Option) -> Result> { - let req = HistoryRequest { - session, - range: range.map(|r| generated::history::Range { - start: r.start.unix_timestamp() as u64, - end: r.end.unix_timestamp() as u64, - }), - }; - - let reply = self.client.history(req).await?.into_inner(); - - Ok(reply - .entries - .into_iter() - .map(history_entry_to_history) - .collect()) - } - - pub async fn end_history( - &mut self, - id: String, - duration: u64, - exit: i64, - ) -> Result { - let req = EndHistoryRequest { id, exit, duration }; - - Ok(self.client.end_history(req).await?.into_inner()) - } - - pub async fn tail_history(&mut self) -> Result> { - Ok(self - .client - .tail_history(TailHistoryRequest {}) - .await? - .into_inner()) - } -} - -// ============================================================================ -// Control Client -// ============================================================================ - -/// Client for the Control gRPC service. -#[derive(Debug)] -pub struct ControlClient { - client: ControlServiceClient, -} - -impl ControlClient { - /// Connect to the daemon's control service. - pub async fn new(path: String) -> Result { - let log_path = path.clone(); - let channel = Endpoint::try_from("http://atuin_local_daemon:0")? - .connect_with_connector(service_fn(move |_: Uri| { - let path = path.clone(); - - async move { - Ok::<_, std::io::Error>(TokioIo::new(UnixStream::connect(path.clone()).await?)) - } - })) - .await - .wrap_err_with(|| { - format!( - "failed to connect to local atuin daemon at {}. Is it running?", - &log_path - ) - })?; - - let client = ControlServiceClient::new(channel); - - Ok(Self { client }) - } - - pub async fn paths(&mut self) -> Result { - Ok(self.client.paths(PathsRequest {}).await?.into_inner()) - } - - pub async fn force_sync(&mut self) -> Result { - Ok(self - .client - .force_sync(ForceSyncRequest {}) - .await? - .into_inner()) - } - - pub async fn status(&mut self) -> Result { - Ok(self.client.status(StatusRequest {}).await?.into_inner()) - } -} diff --git a/crates/daemon/src/api/control.rs b/crates/daemon/src/api/control.rs new file mode 100644 index 00000000..a9d9cff3 --- /dev/null +++ b/crates/daemon/src/api/control.rs @@ -0,0 +1,280 @@ +use std::time::Duration; + +use eyre::Result; +use rand::Rng; +use tokio::time::{self, MissedTickBehavior}; +use tonic::{Request, Response, Status}; +use tracing::{Level, instrument}; + +use turtle::generated::{ + DAEMON_PROTOCOL_VERSION, + control::{ + ForceSyncReply, ForceSyncRequest, PathsReply, PathsRequest, StatusReply, StatusRequest, + control_server::{Control, ControlServer}, + }, +}; + +use crate::{ + DAEMON_VERSION, + aclient::{history::store::HistoryStore, record::sync, settings::Settings}, + daemon::DaemonHandle, + events::DaemonEvent, +}; + +/// Sync state - tracks whether we're in normal operation or retrying after failure. +#[derive(Clone, Copy, PartialEq, Eq)] +enum SyncState { + /// Normal operation. Periodic syncs only run if [`auto_sync`] is enabled. + Idle, + /// Retrying after a sync failure. Retries continue regardless of [`auto_sync`] + /// until the sync succeeds. + Retrying, +} + +/// The Control gRPC service. +/// +/// This service is used by external processes to inject events into the daemon. +/// It's not a component - it's part of the daemon's core infrastructure. +pub(crate) struct ControlService { + handle: DaemonHandle, + task_handle: tokio::task::JoinHandle<()>, +} + +impl ControlService { + /// Create a new control service with the given daemon handle. + pub(crate) fn new(handle: DaemonHandle) -> Self { + let task_handle = tokio::spawn(sync_loop(handle.clone())); + + Self { + handle, + task_handle, + } + } + + /// Get a tonic server for this service. + pub(crate) fn into_server(self) -> ControlServer { + ControlServer::new(self) + } +} + +#[tonic::async_trait] +impl Control for ControlService { + #[instrument(skip_all, level = Level::INFO)] + async fn paths(&self, _request: Request) -> Result, Status> { + let settings = self.handle.settings().await; + + let config = Settings::get_config_path() + .map_err(|e| Status::internal(format!("failed to get settings path: {e:?}")))?; + + let reply = PathsReply { + config: config.to_string_lossy().to_string(), + db: settings.db_path.clone(), + socket: settings.daemon.socket_path.clone(), + }; + + Ok(Response::new(reply)) + } + + #[instrument(skip_all, level = Level::INFO)] + async fn status( + &self, + _request: Request, + ) -> Result, Status> { + let reply = StatusReply { + healthy: true, + version: DAEMON_VERSION.to_owned(), + pid: std::process::id(), + protocol: DAEMON_PROTOCOL_VERSION, + }; + + Ok(Response::new(reply)) + } + + #[instrument(skip_all, level = Level::INFO)] + async fn force_sync( + &self, + _request: Request, + ) -> Result, Status> { + let reply = ForceSyncReply { accepted: false }; + + Ok(Response::new(reply)) + } +} + +/// The main sync loop. +/// +/// This runs in a spawned task and handles periodic sync as well as +/// force sync requests. +#[expect(clippy::significant_drop_tightening, reason = "false positive")] +async fn sync_loop(handle: DaemonHandle) { + tracing::info!("sync loop starting"); + + // Clone settings since we need them across await points + let settings = handle.settings().await.clone(); + let host_id = match Settings::host_id().await { + Ok(id) => id, + Err(e) => { + tracing::error!("failed to get host id, sync disabled: {e}"); + return; + } + }; + + // Create the stores we need + let encryption_key = *handle.encryption_key(); + let history_store = HistoryStore::new(handle.store().clone(), host_id, encryption_key); + + // Don't backoff by more than 30 mins (with a random jitter of up to 1 min) + let max_interval: f64 = 60.0f64.mul_add(30.0, rand::thread_rng().gen_range(0.0..60.0)); + + let mut ticker = time::interval(Duration::from_secs(settings.daemon.sync_frequency)); + + // IMPORTANT: without this, if we miss ticks because a sync takes ages or is otherwise delayed, + // we may end up running a lot of syncs in a hot loop. + ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); + + let mut sync_state = SyncState::Idle; + + let mut daemon_rx = handle.subscribe(); + loop { + tokio::select! { + _ = ticker.tick() => { + let settings = handle.settings().await; + + // Skip periodic ticks if auto_sync is disabled AND we're not retrying + // a previous failure. Retries must continue regardless of auto_sync. + if !settings.sync.auto && sync_state == SyncState::Idle { + tracing::debug!("auto_sync disabled, skipping periodic sync tick"); + continue; + } + + sync_state = do_sync_tick( + &handle, + &history_store, + &mut ticker, + max_interval, + &settings, + ).await; + } + cmd = daemon_rx.recv() => { + match cmd { + Ok(DaemonEvent::ForceSync) => { + tracing::info!("executing force sync"); + let settings = handle.settings().await; + sync_state = do_sync_tick( + &handle, + &history_store, + &mut ticker, + max_interval, + &settings, + ).await; + }, + Ok(DaemonEvent::ShutdownRequested) | Err(_) => { + tracing::info!("sync loop stopping"); + break; + }, + _ => () + } + } + } + } +} + +/// Execute a single sync tick. +/// +/// Returns the new sync state: `Idle` on success, `Retrying` on failure. +async fn do_sync_tick( + handle: &DaemonHandle, + history_store: &HistoryStore, + ticker: &mut time::Interval, + max_interval: f64, + settings: &Settings, +) -> SyncState { + tracing::info!("sync tick"); + + // Check if logged in + let logged_in = match settings.sync.have_sync_user() { + Ok(v) => v, + Err(e) => { + tracing::warn!("failed to check login status, skipping sync tick: {e}"); + return SyncState::Idle; + } + }; + + if !logged_in { + tracing::debug!("not logged in, skipping sync tick"); + return SyncState::Idle; + } + + // Perform the sync + let res = sync::sync(settings, handle.store(), handle.encryption_key()).await; + + match res { + Err(e) => { + tracing::error!("sync tick failed with {e}"); + + // Emit failure event + handle.emit(DaemonEvent::SyncFailed { + error: e.to_string(), + }); + + // Exponential backoff + let mut rng = rand::thread_rng(); + let mut new_interval = ticker.period().as_secs_f64() * rng.gen_range(2.0..2.2); + + if new_interval > max_interval { + new_interval = max_interval; + } + + *ticker = time::interval_at( + time::Instant::now() + Duration::from_secs(new_interval as u64), + Duration::from_secs(new_interval as u64), + ); + ticker.reset_after(Duration::from_secs(new_interval as u64)); + ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); + + tracing::error!("backing off, next sync tick in {new_interval}"); + + SyncState::Retrying + } + Ok((uploaded_count, downloaded_records)) => { + tracing::info!( + uploaded = uploaded_count, + downloaded = downloaded_records.len(), + "sync complete" + ); + + // Build history from downloaded records + if let Err(e) = history_store + .incremental_build(handle.history_db(), &downloaded_records) + .await + { + tracing::error!("failed to build history from downloaded records: {e}"); + } + + // Emit the records added event (for search indexing) + handle.emit(DaemonEvent::RecordsAdded(downloaded_records.clone())); + + // Emit sync completed event + handle.emit(DaemonEvent::SyncCompleted { + uploaded: uploaded_count as usize, + downloaded: downloaded_records.len(), + }); + + // Reset backoff on success + if ticker.period().as_secs() != settings.daemon.sync_frequency { + *ticker = time::interval_at( + time::Instant::now() + Duration::from_secs(settings.daemon.sync_frequency), + Duration::from_secs(settings.daemon.sync_frequency), + ); + ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); + } + + // Store sync time + if let Err(e) = Settings::save_sync_time().await { + tracing::error!("failed to save sync time: {e}"); + } + + SyncState::Idle + } + } +} diff --git a/crates/daemon/src/api/generated.rs b/crates/daemon/src/api/generated.rs deleted file mode 100644 index 304edcd9..00000000 --- a/crates/daemon/src/api/generated.rs +++ /dev/null @@ -1,28 +0,0 @@ -#![expect( - unreachable_pub, - unused_qualifications, - clippy::doc_markdown, - clippy::default_trait_access, - clippy::too_many_lines, - clippy::trivially_copy_pass_by_ref, - clippy::allow_attributes, - clippy::derive_partial_eq_without_eq, - reason = "All of these lints are triggered by the generated code" -)] - -/// Semantic command capture gRPC service types. -pub(crate) mod semantic { - tonic::include_proto!("semantic"); -} - -/// History module for the daemon gRPC history service. -/// -/// This module contains the proto-generated types for the history gRPC service. -pub(crate) mod history { - tonic::include_proto!("history"); -} - -/// Control module for external control. -pub(crate) mod control { - tonic::include_proto!("control"); -} diff --git a/crates/daemon/src/api/history.rs b/crates/daemon/src/api/history.rs new file mode 100644 index 00000000..bcd2ee5a --- /dev/null +++ b/crates/daemon/src/api/history.rs @@ -0,0 +1,253 @@ +use std::pin::Pin; + +use dashmap::DashMap; +use eyre::Result; +use time::OffsetDateTime; +use tokio_stream::Stream; +use tonic::{Request, Response, Status}; +use tracing::{Level, instrument}; + +use crate::{ + aclient::{ + database::{ClientSqlite, current_context}, + history::store::HistoryStore, + settings::Settings, + }, + daemon::DaemonHandle, + events::DaemonEvent, +}; +use turtle::{ + generated::{ + DAEMON_PROTOCOL_VERSION, + history::{ + EndHistoryReply, EndHistoryRequest, HistoryEntry, HistoryEventKind, HistoryReply, + HistoryRequest, StartHistoryReply, StartHistoryRequest, TailHistoryReply, + TailHistoryRequest, + history_server::{History as HistorySvc, HistoryServer}, + }, + }, + history::{History, HistoryId}, +}; + +/// The gRPC service implementation. +/// +/// This is a thin wrapper that delegates to the component's shared state. +pub(crate) struct HistoryService { + /// Commands currently running (not yet completed). + running: DashMap, + + /// Handle to the daemon (set during start). + handle: DaemonHandle, + + /// History store for pushing records + history_store: HistoryStore, + + history_db: ClientSqlite, +} + +impl HistoryService { + pub(crate) async fn new(handle: DaemonHandle, history_db: ClientSqlite) -> Result { + let host_id = Settings::host_id().await?; + let history_store = + HistoryStore::new(handle.store().clone(), host_id, *handle.encryption_key()); + + Ok(Self { + running: DashMap::new(), + handle, + history_store, + history_db, + }) + } + + /// Get a tonic server for this service. + pub(crate) fn into_server(self) -> HistoryServer { + HistoryServer::new(self) + } +} + +fn history_to_reply(history: History) -> HistoryEntry { + HistoryEntry { + timestamp: history.timestamp.unix_timestamp_nanos() as u64, + id: history.id.0, + command: history.command, + cwd: history.cwd, + session: history.session, + hostname: history.hostname, + author: history.author, + intent: history.intent.unwrap_or_default(), + exit: history.exit, + duration: history.duration, + } +} + +#[tonic::async_trait] +impl HistorySvc for HistoryService { + type TailHistoryStream = Pin> + Send>>; + + #[instrument(skip_all, level = Level::INFO)] + async fn history( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + let context = current_context(req.session) + .await + .map_err(|e| Status::internal(format!("failed to aquire context: {e:?}")))?; + + let entries = if let Some(range) = req.range { + let from = OffsetDateTime::from_unix_timestamp(range.start as i64).unwrap(); + let to = OffsetDateTime::from_unix_timestamp(range.end as i64).unwrap(); + + self.history_db.range(from, to).await + } else { + self.history_db + .list(&[], &context, None, false, false) + .await + } + .map_err(|e| Status::internal(format!("failed to read db: {e:?}")))? + .into_iter() + .map(history_to_reply) + .collect(); + + Ok(Response::new(HistoryReply { entries })) + } + + #[instrument(skip_all, level = Level::INFO)] + async fn start_history( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + let timestamp = OffsetDateTime::from_unix_timestamp_nanos(i128::from(req.timestamp)) + .map_err(|_| { + Status::invalid_argument( + "failed to parse timestamp as unix time (expected nanos since epoch)", + ) + })?; + + let h: History = History::daemon() + .timestamp(timestamp) + .command(req.command) + .cwd(req.cwd) + .session(req.session) + .hostname(req.hostname) + .author(req.author) + .intent(req.intent) + .build() + .into(); + + self.handle.emit(DaemonEvent::HistoryStarted(h.clone())); + + let id = h.id.clone(); + tracing::info!(id = id.to_string(), "start history"); + self.running.insert(id.clone(), h); + + let reply = StartHistoryReply { + id: id.to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + protocol: DAEMON_PROTOCOL_VERSION, + }; + + Ok(Response::new(reply)) + } + + #[instrument(skip_all, level = Level::INFO)] + async fn end_history( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let id = HistoryId(req.id); + + if let Some((_, mut history)) = self.running.remove(&id) { + history.exit = req.exit; + history.duration = match req.duration { + 0 => i64::try_from( + (OffsetDateTime::now_utc() - history.timestamp).whole_nanoseconds(), + ) + .expect("failed to convert calculated duration to i64"), + value => i64::try_from(value).expect("failed to get i64 duration"), + }; + + self.handle + .history_db() + .save(&history) + .await + .map_err(|e| Status::internal(format!("failed to write to db: {e:?}")))?; + + tracing::info!(id = id.0, duration = history.duration, "end history"); + + let (record_id, idx) = self + .history_store + .push(history.clone()) + .await + .map_err(|e| Status::internal(format!("failed to push record to store: {e:?}")))?; + + self.handle.emit(DaemonEvent::HistoryEnded(history)); + + let reply = EndHistoryReply { + id: record_id.0.to_string(), + idx, + version: env!("CARGO_PKG_VERSION").to_string(), + protocol: DAEMON_PROTOCOL_VERSION, + }; + + return Ok(Response::new(reply)); + } + + Err(Status::not_found(format!( + "could not find history with id: {id}" + ))) + } + + #[instrument(skip_all, level = Level::INFO)] + #[expect(clippy::significant_drop_tightening, reason = "Would be a logic-bug")] + async fn tail_history( + &self, + _request: Request, + ) -> Result, Status> { + let mut rx = self.handle.subscribe(); + let (tx, out_rx) = tokio::sync::mpsc::channel::>(128); + + tokio::spawn(async move { + loop { + let event = match rx.recv().await { + Ok(event) => event, + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + drop( + tx.send(Err(Status::resource_exhausted(format!( + "tail stream lagged behind and dropped {skipped} events" + )))) + .await, + ); + break; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + }; + + let reply = match event { + DaemonEvent::HistoryStarted(history) => Some(TailHistoryReply { + kind: HistoryEventKind::Started.into(), + history: Some(history_to_reply(history)), + }), + DaemonEvent::HistoryEnded(history) => Some(TailHistoryReply { + kind: HistoryEventKind::Ended.into(), + history: Some(history_to_reply(history)), + }), + _ => None, + }; + + if let Some(reply) = reply + && tx.send(Ok(reply)).await.is_err() + { + break; + } + } + }); + + let stream = tokio_stream::wrappers::ReceiverStream::new(out_rx); + Ok(Response::new(Box::pin(stream))) + } +} diff --git a/crates/daemon/src/api/mod.rs b/crates/daemon/src/api/mod.rs index b7f82a7b..8d475fe9 100644 --- a/crates/daemon/src/api/mod.rs +++ b/crates/daemon/src/api/mod.rs @@ -1,6 +1,2 @@ -pub mod client; -pub(crate) mod generated; -pub(crate) mod server; - -pub(crate) const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION"); -const DAEMON_PROTOCOL_VERSION: u32 = 1; +pub(crate) mod control; +pub(crate) mod history; diff --git a/crates/daemon/src/api/server/control.rs b/crates/daemon/src/api/server/control.rs deleted file mode 100644 index a5e26355..00000000 --- a/crates/daemon/src/api/server/control.rs +++ /dev/null @@ -1,278 +0,0 @@ -use std::time::Duration; - -use eyre::Result; -use rand::Rng; -use tokio::time::{self, MissedTickBehavior}; -use tonic::{Request, Response, Status}; -use tracing::{Level, instrument}; - -use crate::{ - aclient::{history::store::HistoryStore, record::sync, settings::Settings}, - api::{ - DAEMON_PROTOCOL_VERSION, DAEMON_VERSION, - generated::control::{ - ForceSyncReply, ForceSyncRequest, PathsReply, PathsRequest, StatusReply, StatusRequest, - control_server::{Control, ControlServer}, - }, - }, - daemon::DaemonHandle, - events::DaemonEvent, -}; - -/// Sync state - tracks whether we're in normal operation or retrying after failure. -#[derive(Clone, Copy, PartialEq, Eq)] -enum SyncState { - /// Normal operation. Periodic syncs only run if [`auto_sync`] is enabled. - Idle, - /// Retrying after a sync failure. Retries continue regardless of [`auto_sync`] - /// until the sync succeeds. - Retrying, -} - -/// The Control gRPC service. -/// -/// This service is used by external processes to inject events into the daemon. -/// It's not a component - it's part of the daemon's core infrastructure. -pub(crate) struct ControlService { - handle: DaemonHandle, - task_handle: tokio::task::JoinHandle<()>, -} - -impl ControlService { - /// Create a new control service with the given daemon handle. - pub(crate) fn new(handle: DaemonHandle) -> Self { - let task_handle = tokio::spawn(sync_loop(handle.clone())); - - Self { - handle, - task_handle, - } - } - - /// Get a tonic server for this service. - pub(crate) fn into_server(self) -> ControlServer { - ControlServer::new(self) - } -} - -#[tonic::async_trait] -impl Control for ControlService { - #[instrument(skip_all, level = Level::INFO)] - async fn paths(&self, _request: Request) -> Result, Status> { - let settings = self.handle.settings().await; - - let config = Settings::get_config_path() - .map_err(|e| Status::internal(format!("failed to get settings path: {e:?}")))?; - - let reply = PathsReply { - config: config.to_string_lossy().to_string(), - db: settings.db_path.clone(), - socket: settings.daemon.socket_path.clone(), - }; - - Ok(Response::new(reply)) - } - - #[instrument(skip_all, level = Level::INFO)] - async fn status( - &self, - _request: Request, - ) -> Result, Status> { - let reply = StatusReply { - healthy: true, - version: DAEMON_VERSION.to_owned(), - pid: std::process::id(), - protocol: DAEMON_PROTOCOL_VERSION, - }; - - Ok(Response::new(reply)) - } - - #[instrument(skip_all, level = Level::INFO)] - async fn force_sync( - &self, - _request: Request, - ) -> Result, Status> { - let reply = ForceSyncReply { accepted: false }; - - Ok(Response::new(reply)) - } -} - -/// The main sync loop. -/// -/// This runs in a spawned task and handles periodic sync as well as -/// force sync requests. -#[expect(clippy::significant_drop_tightening, reason = "false positive")] -async fn sync_loop(handle: DaemonHandle) { - tracing::info!("sync loop starting"); - - // Clone settings since we need them across await points - let settings = handle.settings().await.clone(); - let host_id = match Settings::host_id().await { - Ok(id) => id, - Err(e) => { - tracing::error!("failed to get host id, sync disabled: {e}"); - return; - } - }; - - // Create the stores we need - let encryption_key = *handle.encryption_key(); - let history_store = HistoryStore::new(handle.store().clone(), host_id, encryption_key); - - // Don't backoff by more than 30 mins (with a random jitter of up to 1 min) - let max_interval: f64 = 60.0f64.mul_add(30.0, rand::thread_rng().gen_range(0.0..60.0)); - - let mut ticker = time::interval(Duration::from_secs(settings.daemon.sync_frequency)); - - // IMPORTANT: without this, if we miss ticks because a sync takes ages or is otherwise delayed, - // we may end up running a lot of syncs in a hot loop. - ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); - - let mut sync_state = SyncState::Idle; - - let mut daemon_rx = handle.subscribe(); - loop { - tokio::select! { - _ = ticker.tick() => { - let settings = handle.settings().await; - - // Skip periodic ticks if auto_sync is disabled AND we're not retrying - // a previous failure. Retries must continue regardless of auto_sync. - if !settings.sync.auto && sync_state == SyncState::Idle { - tracing::debug!("auto_sync disabled, skipping periodic sync tick"); - continue; - } - - sync_state = do_sync_tick( - &handle, - &history_store, - &mut ticker, - max_interval, - &settings, - ).await; - } - cmd = daemon_rx.recv() => { - match cmd { - Ok(DaemonEvent::ForceSync) => { - tracing::info!("executing force sync"); - let settings = handle.settings().await; - sync_state = do_sync_tick( - &handle, - &history_store, - &mut ticker, - max_interval, - &settings, - ).await; - }, - Ok(DaemonEvent::ShutdownRequested) | Err(_) => { - tracing::info!("sync loop stopping"); - break; - }, - _ => () - } - } - } - } -} - -/// Execute a single sync tick. -/// -/// Returns the new sync state: `Idle` on success, `Retrying` on failure. -async fn do_sync_tick( - handle: &DaemonHandle, - history_store: &HistoryStore, - ticker: &mut time::Interval, - max_interval: f64, - settings: &Settings, -) -> SyncState { - tracing::info!("sync tick"); - - // Check if logged in - let logged_in = match settings.sync.have_sync_user() { - Ok(v) => v, - Err(e) => { - tracing::warn!("failed to check login status, skipping sync tick: {e}"); - return SyncState::Idle; - } - }; - - if !logged_in { - tracing::debug!("not logged in, skipping sync tick"); - return SyncState::Idle; - } - - // Perform the sync - let res = sync::sync(settings, handle.store(), handle.encryption_key()).await; - - match res { - Err(e) => { - tracing::error!("sync tick failed with {e}"); - - // Emit failure event - handle.emit(DaemonEvent::SyncFailed { - error: e.to_string(), - }); - - // Exponential backoff - let mut rng = rand::thread_rng(); - let mut new_interval = ticker.period().as_secs_f64() * rng.gen_range(2.0..2.2); - - if new_interval > max_interval { - new_interval = max_interval; - } - - *ticker = time::interval_at( - time::Instant::now() + Duration::from_secs(new_interval as u64), - Duration::from_secs(new_interval as u64), - ); - ticker.reset_after(Duration::from_secs(new_interval as u64)); - ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); - - tracing::error!("backing off, next sync tick in {new_interval}"); - - SyncState::Retrying - } - Ok((uploaded_count, downloaded_records)) => { - tracing::info!( - uploaded = uploaded_count, - downloaded = downloaded_records.len(), - "sync complete" - ); - - // Build history from downloaded records - if let Err(e) = history_store - .incremental_build(handle.history_db(), &downloaded_records) - .await - { - tracing::error!("failed to build history from downloaded records: {e}"); - } - - // Emit the records added event (for search indexing) - handle.emit(DaemonEvent::RecordsAdded(downloaded_records.clone())); - - // Emit sync completed event - handle.emit(DaemonEvent::SyncCompleted { - uploaded: uploaded_count as usize, - downloaded: downloaded_records.len(), - }); - - // Reset backoff on success - if ticker.period().as_secs() != settings.daemon.sync_frequency { - *ticker = time::interval_at( - time::Instant::now() + Duration::from_secs(settings.daemon.sync_frequency), - Duration::from_secs(settings.daemon.sync_frequency), - ); - ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); - } - - // Store sync time - if let Err(e) = Settings::save_sync_time().await { - tracing::error!("failed to save sync time: {e}"); - } - - SyncState::Idle - } - } -} diff --git a/crates/daemon/src/api/server/history.rs b/crates/daemon/src/api/server/history.rs deleted file mode 100644 index 0edf3b94..00000000 --- a/crates/daemon/src/api/server/history.rs +++ /dev/null @@ -1,250 +0,0 @@ -use std::pin::Pin; - -use dashmap::DashMap; -use eyre::Result; -use time::OffsetDateTime; -use tokio_stream::Stream; -use tonic::{Request, Response, Status}; -use tracing::{Level, instrument}; - -use crate::{ - aclient::{ - database::{ClientSqlite, current_context}, - history::{History, HistoryId, store::HistoryStore}, - settings::Settings, - }, - api::{ - DAEMON_PROTOCOL_VERSION, - generated::history::{ - EndHistoryReply, EndHistoryRequest, HistoryEntry, HistoryEventKind, HistoryReply, - HistoryRequest, StartHistoryReply, StartHistoryRequest, TailHistoryReply, - TailHistoryRequest, - history_server::{History as HistorySvc, HistoryServer}, - }, - }, - daemon::DaemonHandle, - events::DaemonEvent, -}; - -/// The gRPC service implementation. -/// -/// This is a thin wrapper that delegates to the component's shared state. -pub(crate) struct HistoryService { - /// Commands currently running (not yet completed). - running: DashMap, - - /// Handle to the daemon (set during start). - handle: DaemonHandle, - - /// History store for pushing records - history_store: HistoryStore, - - history_db: ClientSqlite, -} - -impl HistoryService { - pub(crate) async fn new(handle: DaemonHandle, history_db: ClientSqlite) -> Result { - let host_id = Settings::host_id().await?; - let history_store = - HistoryStore::new(handle.store().clone(), host_id, *handle.encryption_key()); - - Ok(Self { - running: DashMap::new(), - handle, - history_store, - history_db, - }) - } - - /// Get a tonic server for this service. - pub(crate) fn into_server(self) -> HistoryServer { - HistoryServer::new(self) - } -} - -fn history_to_reply(history: History) -> HistoryEntry { - HistoryEntry { - timestamp: history.timestamp.unix_timestamp_nanos() as u64, - id: history.id.0, - command: history.command, - cwd: history.cwd, - session: history.session, - hostname: history.hostname, - author: history.author, - intent: history.intent.unwrap_or_default(), - exit: history.exit, - duration: history.duration, - } -} - -#[tonic::async_trait] -impl HistorySvc for HistoryService { - type TailHistoryStream = Pin> + Send>>; - - #[instrument(skip_all, level = Level::INFO)] - async fn history( - &self, - request: Request, - ) -> Result, Status> { - let req = request.into_inner(); - - let context = current_context(req.session) - .await - .map_err(|e| Status::internal(format!("failed to aquire context: {e:?}")))?; - - let entries = if let Some(range) = req.range { - let from = OffsetDateTime::from_unix_timestamp(range.start as i64).unwrap(); - let to = OffsetDateTime::from_unix_timestamp(range.end as i64).unwrap(); - - self.history_db.range(from, to).await - } else { - self.history_db - .list(&[], &context, None, false, false) - .await - } - .map_err(|e| Status::internal(format!("failed to read db: {e:?}")))? - .into_iter() - .map(history_to_reply) - .collect(); - - Ok(Response::new(HistoryReply { entries })) - } - - #[instrument(skip_all, level = Level::INFO)] - async fn start_history( - &self, - request: Request, - ) -> Result, Status> { - let req = request.into_inner(); - - let timestamp = OffsetDateTime::from_unix_timestamp_nanos(i128::from(req.timestamp)) - .map_err(|_| { - Status::invalid_argument( - "failed to parse timestamp as unix time (expected nanos since epoch)", - ) - })?; - - let h: History = History::daemon() - .timestamp(timestamp) - .command(req.command) - .cwd(req.cwd) - .session(req.session) - .hostname(req.hostname) - .author(req.author) - .intent(req.intent) - .build() - .into(); - - self.handle.emit(DaemonEvent::HistoryStarted(h.clone())); - - let id = h.id.clone(); - tracing::info!(id = id.to_string(), "start history"); - self.running.insert(id.clone(), h); - - let reply = StartHistoryReply { - id: id.to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - protocol: DAEMON_PROTOCOL_VERSION, - }; - - Ok(Response::new(reply)) - } - - #[instrument(skip_all, level = Level::INFO)] - async fn end_history( - &self, - request: Request, - ) -> Result, Status> { - let req = request.into_inner(); - let id = HistoryId(req.id); - - if let Some((_, mut history)) = self.running.remove(&id) { - history.exit = req.exit; - history.duration = match req.duration { - 0 => i64::try_from( - (OffsetDateTime::now_utc() - history.timestamp).whole_nanoseconds(), - ) - .expect("failed to convert calculated duration to i64"), - value => i64::try_from(value).expect("failed to get i64 duration"), - }; - - self.handle - .history_db() - .save(&history) - .await - .map_err(|e| Status::internal(format!("failed to write to db: {e:?}")))?; - - tracing::info!(id = id.0, duration = history.duration, "end history"); - - let (record_id, idx) = self - .history_store - .push(history.clone()) - .await - .map_err(|e| Status::internal(format!("failed to push record to store: {e:?}")))?; - - self.handle.emit(DaemonEvent::HistoryEnded(history)); - - let reply = EndHistoryReply { - id: record_id.0.to_string(), - idx, - version: env!("CARGO_PKG_VERSION").to_string(), - protocol: DAEMON_PROTOCOL_VERSION, - }; - - return Ok(Response::new(reply)); - } - - Err(Status::not_found(format!( - "could not find history with id: {id}" - ))) - } - - #[instrument(skip_all, level = Level::INFO)] - #[expect(clippy::significant_drop_tightening, reason = "Would be a logic-bug")] - async fn tail_history( - &self, - _request: Request, - ) -> Result, Status> { - let mut rx = self.handle.subscribe(); - let (tx, out_rx) = tokio::sync::mpsc::channel::>(128); - - tokio::spawn(async move { - loop { - let event = match rx.recv().await { - Ok(event) => event, - Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { - drop( - tx.send(Err(Status::resource_exhausted(format!( - "tail stream lagged behind and dropped {skipped} events" - )))) - .await, - ); - break; - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, - }; - - let reply = match event { - DaemonEvent::HistoryStarted(history) => Some(TailHistoryReply { - kind: HistoryEventKind::Started.into(), - history: Some(history_to_reply(history)), - }), - DaemonEvent::HistoryEnded(history) => Some(TailHistoryReply { - kind: HistoryEventKind::Ended.into(), - history: Some(history_to_reply(history)), - }), - _ => None, - }; - - if let Some(reply) = reply - && tx.send(Ok(reply)).await.is_err() - { - break; - } - } - }); - - let stream = tokio_stream::wrappers::ReceiverStream::new(out_rx); - Ok(Response::new(Box::pin(stream))) - } -} diff --git a/crates/daemon/src/api/server/mod.rs b/crates/daemon/src/api/server/mod.rs deleted file mode 100644 index 8d475fe9..00000000 --- a/crates/daemon/src/api/server/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub(crate) mod control; -pub(crate) mod history; diff --git a/crates/daemon/src/events.rs b/crates/daemon/src/events.rs index e3f6ebc9..1864d224 100644 --- a/crates/daemon/src/events.rs +++ b/crates/daemon/src/events.rs @@ -7,7 +7,7 @@ //! External processes (like CLI commands) can also inject events via the //! Control gRPC service. -use crate::aclient::history::{History, HistoryId}; +use turtle::history::{History, HistoryId}; use turtle_common::record::RecordId; /// Events that flow through the daemon's event bus. diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs deleted file mode 100644 index c6877096..00000000 --- a/crates/daemon/src/lib.rs +++ /dev/null @@ -1,161 +0,0 @@ -#![expect(unused_crate_dependencies, reason = "Didn't remove them yet")] - -use std::{ - fs::{self, File, OpenOptions}, - io::Write, - path::{Path, PathBuf}, - time::{Duration, Instant}, -}; - -use eyre::{Context, Result, bail, eyre}; -use fs4::fs_std::FileExt; -use tokio::time::sleep; - -use crate::{ - aclient::{database::ClientSqlite, record::sqlite_store::SqliteStore, settings::Settings}, - api::{ - DAEMON_VERSION, - server::{control::ControlService, history::HistoryService}, - }, - daemon::Daemon, -}; - -pub mod aclient; - -pub mod api; -pub(crate) mod daemon; -pub(crate) mod events; -pub(crate) mod server; - -/// Boot the daemon. -/// -/// This creates a daemon, -/// starts the gRPC server with services, and runs the event loop. -pub async fn boot(settings: Settings, store: SqliteStore, history_db: ClientSqlite) -> Result<()> { - let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path); - let _pidfile_guard = PidfileGuard::acquire(&pidfile_path)?; - - let mut daemon = Daemon::builder(settings.clone()) - .store(store) - .history_db(history_db.clone()) - .build()?; - - let handle = { - let handle = daemon.handle(); - - // Spawn signal handler to emit ShutdownRequested on Ctrl+C/SIGTERM - let signal_handle = handle.clone(); - tokio::spawn(async move { - shutdown_signal().await; - tracing::info!("received shutdown signal"); - signal_handle.shutdown(); - }); - - handle - }; - - let history_service = HistoryService::new(handle.clone(), history_db).await?; - let control_service = ControlService::new(handle.clone()); - - server::run_grpc_server( - &settings, - history_service.into_server(), - control_service.into_server(), - handle, - )?; - - daemon.run_event_loop().await?; - - tracing::info!("daemon shut down complete"); - Ok(()) -} - -/// Wait for a shutdown signal (Ctrl+C or SIGTERM). -#[cfg(unix)] -async fn shutdown_signal() { - let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - .expect("failed to register sigterm handler"); - let mut int = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) - .expect("failed to register sigint handler"); - - tokio::select! { - _ = term.recv() => {}, - _ = int.recv() => {}, - } -} - -struct PidfileGuard { - file: File, -} - -impl PidfileGuard { - fn acquire(path: &Path) -> Result { - let mut file = open_lock_file(path)?; - - if !file.try_lock_exclusive()? { - bail!( - "daemon already running (pidfile lock busy at {})", - path.display() - ); - } - - file.set_len(0) - .wrap_err_with(|| format!("could not truncate daemon pidfile {}", path.display()))?; - writeln!(file, "{}", std::process::id()) - .and_then(|()| writeln!(file, "{DAEMON_VERSION}")) - .wrap_err_with(|| format!("could not write daemon pidfile {}", path.display()))?; - - Ok(Self { file }) - } -} - -impl Drop for PidfileGuard { - fn drop(&mut self) { - drop(self.file.unlock()); - } -} - -fn open_lock_file(path: &Path) -> Result { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .wrap_err_with(|| format!("could not create lock directory {}", parent.display()))?; - } - - OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(path) - .wrap_err_with(|| format!("could not open lock file {}", path.display())) -} - -async fn wait_for_lock(path: &Path, timeout: Duration) -> Result { - const LOCK_POLL: Duration = Duration::from_millis(20); - - let file = open_lock_file(path)?; - let start = Instant::now(); - - loop { - match file.try_lock_exclusive() { - Ok(true) => return Ok(file), - Ok(false) => { - if start.elapsed() >= timeout { - bail!("timed out waiting for lock at {}", path.display()); - } - - sleep(LOCK_POLL).await; - } - Err(err) => { - return Err(eyre!("could not lock {}: {err}", path.display())); - } - } - } -} - -async fn wait_for_pidfile_available(path: &Path, timeout: Duration) -> Result<()> { - let file = wait_for_lock(path, timeout).await?; - file.unlock() - .wrap_err_with(|| format!("failed to unlock {}", path.display()))?; - Ok(()) -} diff --git a/crates/daemon/src/main.rs b/crates/daemon/src/main.rs index 46d9ad4d..59d4c7ff 100644 --- a/crates/daemon/src/main.rs +++ b/crates/daemon/src/main.rs @@ -1,15 +1,35 @@ -#![expect(unused_crate_dependencies, reason = "Didn't remove them yet")] +#![expect(unused_crate_dependencies)] + +use std::{ + fs::{self, File, OpenOptions}, + io::Write, + path::{Path, PathBuf}, + time::{Duration, Instant}, +}; use clap::Parser; -use eyre::{Result, WrapErr}; -use std::path::PathBuf; -use turtle_daemon::aclient::{ - database::ClientSqlite, record::sqlite_store::SqliteStore, settings::Settings, +use eyre::WrapErr; +use eyre::{Context, Result, bail, eyre}; +use fs4::fs_std::FileExt; +use tokio::time::sleep; + +use crate::{ + aclient::{database::ClientSqlite, record::sqlite_store::SqliteStore, settings::Settings}, + api::{control::ControlService, history::HistoryService}, + daemon::Daemon, }; +pub(crate) mod aclient; +pub(crate) mod api; +pub(crate) mod daemon; +pub(crate) mod events; +pub(crate) mod server; + +const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION"); + #[derive(Parser, Debug)] #[command(infer_subcommands = true)] -pub(crate) enum Cmd { +enum Cmd { /// Start the daemon server Start { /// Also write daemon logs to the console (useful for debugging) @@ -28,8 +48,139 @@ async fn main() -> Result<()> { let sqlite_store = SqliteStore::new(record_store_path, settings.local_timeout).await?; match Cmd::parse() { - Cmd::Start { show_logs, .. } => { - turtle_daemon::boot(settings, sqlite_store, history_db).await + Cmd::Start { show_logs, .. } => boot(settings, sqlite_store, history_db).await, + } +} + +/// Boot the daemon. +/// +/// This creates a daemon, +/// starts the gRPC server with services, and runs the event loop. +async fn boot(settings: Settings, store: SqliteStore, history_db: ClientSqlite) -> Result<()> { + let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path); + let _pidfile_guard = PidfileGuard::acquire(&pidfile_path)?; + + let mut daemon = Daemon::builder(settings.clone()) + .store(store) + .history_db(history_db.clone()) + .build()?; + + let handle = { + let handle = daemon.handle(); + + // Spawn signal handler to emit ShutdownRequested on Ctrl+C/SIGTERM + let signal_handle = handle.clone(); + tokio::spawn(async move { + shutdown_signal().await; + tracing::info!("received shutdown signal"); + signal_handle.shutdown(); + }); + + handle + }; + + let history_service = HistoryService::new(handle.clone(), history_db).await?; + let control_service = ControlService::new(handle.clone()); + + server::run_grpc_server( + &settings, + history_service.into_server(), + control_service.into_server(), + handle, + )?; + + daemon.run_event_loop().await?; + + tracing::info!("daemon shut down complete"); + Ok(()) +} + +/// Wait for a shutdown signal (Ctrl+C or SIGTERM). +#[cfg(unix)] +async fn shutdown_signal() { + let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to register sigterm handler"); + let mut int = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) + .expect("failed to register sigint handler"); + + tokio::select! { + _ = term.recv() => {}, + _ = int.recv() => {}, + } +} + +struct PidfileGuard { + file: File, +} + +impl PidfileGuard { + fn acquire(path: &Path) -> Result { + let mut file = open_lock_file(path)?; + + if !file.try_lock_exclusive()? { + bail!( + "daemon already running (pidfile lock busy at {})", + path.display() + ); } + + file.set_len(0) + .wrap_err_with(|| format!("could not truncate daemon pidfile {}", path.display()))?; + writeln!(file, "{}", std::process::id()) + .and_then(|()| writeln!(file, "{DAEMON_VERSION}")) + .wrap_err_with(|| format!("could not write daemon pidfile {}", path.display()))?; + + Ok(Self { file }) + } +} + +impl Drop for PidfileGuard { + fn drop(&mut self) { + drop(self.file.unlock()); + } +} + +fn open_lock_file(path: &Path) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .wrap_err_with(|| format!("could not create lock directory {}", parent.display()))?; } + + OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path) + .wrap_err_with(|| format!("could not open lock file {}", path.display())) +} + +async fn wait_for_lock(path: &Path, timeout: Duration) -> Result { + const LOCK_POLL: Duration = Duration::from_millis(20); + + let file = open_lock_file(path)?; + let start = Instant::now(); + + loop { + match file.try_lock_exclusive() { + Ok(true) => return Ok(file), + Ok(false) => { + if start.elapsed() >= timeout { + bail!("timed out waiting for lock at {}", path.display()); + } + + sleep(LOCK_POLL).await; + } + Err(err) => { + return Err(eyre!("could not lock {}: {err}", path.display())); + } + } + } +} + +async fn wait_for_pidfile_available(path: &Path, timeout: Duration) -> Result<()> { + let file = wait_for_lock(path, timeout).await?; + file.unlock() + .wrap_err_with(|| format!("failed to unlock {}", path.display()))?; + Ok(()) } diff --git a/crates/daemon/src/server.rs b/crates/daemon/src/server.rs index 3400ad62..6747f276 100644 --- a/crates/daemon/src/server.rs +++ b/crates/daemon/src/server.rs @@ -1,19 +1,14 @@ -use std::os::unix::net::SocketAddr; -use std::path::PathBuf; +use std::{os::unix::net::SocketAddr, path::PathBuf}; use eyre::Result; use eyre::{OptionExt, WrapErr}; - -#[cfg(unix)] -use crate::api::server::{control::ControlService, history::HistoryService}; -use crate::{ - aclient::settings::Settings, - api::generated::{ - control::control_server::ControlServer, history::history_server::HistoryServer, - }, - daemon::DaemonHandle, +use turtle::generated::{ + control::control_server::ControlServer, history::history_server::HistoryServer, }; +use crate::api::{control::ControlService, history::HistoryService}; +use crate::{aclient::settings::Settings, daemon::DaemonHandle}; + /// Run the gRPC server with the given services. /// /// This starts the gRPC server in the background and returns immediately. diff --git a/crates/turtle/Cargo.toml b/crates/turtle/Cargo.toml index 3595008d..102c5e9a 100644 --- a/crates/turtle/Cargo.toml +++ b/crates/turtle/Cargo.toml @@ -12,6 +12,7 @@ homepage = { workspace = true } repository = { workspace = true } [dependencies] +turtle-common = {workspace = true} async-trait = "0.1.58" axum = "0.8" base64 = "0.22" diff --git a/crates/turtle/build.rs b/crates/turtle/build.rs new file mode 100644 index 00000000..62612968 --- /dev/null +++ b/crates/turtle/build.rs @@ -0,0 +1,32 @@ +use std::{env, fs, path::PathBuf}; + +use protox::Compiler; +use protox::prost::Message; + +fn main() -> Result<(), std::io::Error> { + let proto_paths = [ + "proto/history.proto", + "proto/control.proto", + ]; + let proto_include_dirs = ["proto"]; + + let file_descriptor_set = Compiler::new(proto_include_dirs) + .map_err(std::io::Error::other)? + .include_source_info(true) + .include_imports(true) + .open_files(proto_paths) + .map_err(std::io::Error::other)? + .file_descriptor_set(); + + let file_descriptor_path = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR not set")) + .join("file_descriptor_set.bin"); + fs::write(&file_descriptor_path, file_descriptor_set.encode_to_vec()).unwrap(); + + tonic_prost_build::configure() + .build_server(true) + .file_descriptor_set_path(&file_descriptor_path) + .skip_protoc_run() + .compile_protos(&proto_paths, &proto_include_dirs)?; + + Ok(()) +} diff --git a/crates/turtle/proto/control.proto b/crates/turtle/proto/control.proto new file mode 100644 index 00000000..a8026cb8 --- /dev/null +++ b/crates/turtle/proto/control.proto @@ -0,0 +1,33 @@ +syntax = "proto3"; +package control; + +service Control { + // Tell the daemon to perform a sync operation. + rpc ForceSync(ForceSyncRequest) returns (ForceSyncReply); + + // Query the daemon for it's status. + rpc Status(StatusRequest) returns (StatusReply); + + // Query the daemon about used paths. + rpc Paths(PathsRequest) returns (PathsReply); +} + +message ForceSyncRequest {} +message ForceSyncReply { + bool accepted = 1; +} + +message StatusRequest {} +message StatusReply { + bool healthy = 1; + string version = 2; + uint32 pid = 3; + uint32 protocol = 4; +} + +message PathsRequest {} +message PathsReply { + string config = 1; + string db = 2; + string socket = 3; +} diff --git a/crates/turtle/proto/history.proto b/crates/turtle/proto/history.proto new file mode 100644 index 00000000..850b16b9 --- /dev/null +++ b/crates/turtle/proto/history.proto @@ -0,0 +1,78 @@ +syntax = "proto3"; +package history; + +service History { + rpc StartHistory(StartHistoryRequest) returns (StartHistoryReply); + rpc EndHistory(EndHistoryRequest) returns (EndHistoryReply); + + rpc TailHistory(TailHistoryRequest) returns (stream TailHistoryReply); + + // Request history from the daemon + rpc History(HistoryRequest) returns (HistoryReply); +} + +message StartHistoryRequest { + uint64 timestamp = 1; // nanosecond unix epoch + string command = 2; + string cwd = 3; + string session = 4; + string hostname = 5; + string author = 6; + string intent = 7; +} +message StartHistoryReply { + string id = 1; + string version = 2; + uint32 protocol = 3; +} + +message EndHistoryRequest { + string id = 1; + int64 exit = 2; + uint64 duration = 3; +} +message EndHistoryReply { + string id = 1; + uint64 idx = 2; + string version = 3; + uint32 protocol = 4; +} + + +message TailHistoryRequest {} +message TailHistoryReply { + HistoryEventKind kind = 1; + HistoryEntry history = 2; +} + +enum HistoryEventKind { + HISTORY_EVENT_KIND_UNSPECIFIED = 0; + HISTORY_EVENT_KIND_STARTED = 1; + HISTORY_EVENT_KIND_ENDED = 2; +} + +message HistoryEntry { + uint64 timestamp = 1; // nanosecond unix epoch + string id = 2; + string command = 3; + string cwd = 4; + string session = 5; + string hostname = 6; + string author = 7; + string intent = 8; + int64 exit = 9; + int64 duration = 10; +} + +message Range { + uint64 start = 1; + uint64 end = 2; +} + +message HistoryRequest { + string session = 1; + optional Range range = 2; +} +message HistoryReply { + repeated HistoryEntry entries = 1; +} diff --git a/crates/turtle/src/client/mod.rs b/crates/turtle/src/client/mod.rs new file mode 100644 index 00000000..07f01e6c --- /dev/null +++ b/crates/turtle/src/client/mod.rs @@ -0,0 +1,267 @@ +use eyre::{Context as EyreContext, Result}; +use time::OffsetDateTime; +use tonic::Code; +use tonic::transport::{Channel, Endpoint, Uri}; +use tower::service_fn; + +use hyper_util::rt::TokioIo; + +#[cfg(unix)] +use tokio::net::UnixStream; + +use crate::generated::{ + self, DAEMON_PROTOCOL_VERSION, + control::{ + ForceSyncReply, ForceSyncRequest, PathsReply, PathsRequest, StatusReply, StatusRequest, + control_client::ControlClient as ControlServiceClient, + }, + history::{ + EndHistoryReply, EndHistoryRequest, HistoryEntry, HistoryRequest, StartHistoryReply, + StartHistoryRequest, TailHistoryRequest, + history_client::HistoryClient as HistoryServiceClient, + }, +}; + +pub use crate::generated::history::{HistoryEventKind, TailHistoryReply}; +use crate::history::History; + +fn normalize_optional_field(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } +} + +pub fn history_entry_to_history(entry: HistoryEntry) -> History { + let timestamp = OffsetDateTime::from_unix_timestamp_nanos(i128::from(entry.timestamp)) + .expect("Daemon history timestamp should always be valid"); + + History { + id: entry.id.into(), + timestamp, + duration: entry.duration, + exit: entry.exit, + command: entry.command, + cwd: entry.cwd, + session: entry.session, + hostname: entry.hostname, + author: entry.author, + intent: normalize_optional_field(&entry.intent), + deleted_at: None, + } +} + +#[must_use] +pub fn daemon_matches_expected(version: &str, protocol: u32) -> bool { + protocol == DAEMON_PROTOCOL_VERSION +} + +#[must_use] +pub fn daemon_mismatch_message(version: &str, protocol: u32) -> String { + if protocol == DAEMON_PROTOCOL_VERSION { + unreachable!() + } else { + format!("daemon protocol mismatch: expected {DAEMON_PROTOCOL_VERSION}, got {protocol}") + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DaemonClientErrorKind { + Connect, + Unavailable, + Unimplemented, + Other, +} + +#[must_use] +pub fn classify_error(error: &eyre::Report) -> DaemonClientErrorKind { + for cause in error.chain() { + if cause.downcast_ref::().is_some() { + return DaemonClientErrorKind::Connect; + } + + if let Some(status) = cause.downcast_ref::() { + return match status.code() { + Code::Unavailable => DaemonClientErrorKind::Unavailable, + Code::Unimplemented => DaemonClientErrorKind::Unimplemented, + _ => DaemonClientErrorKind::Other, + }; + } + } + + DaemonClientErrorKind::Other +} + +#[derive(Debug)] +pub enum Probe { + Ready(ControlClient), + NeedsRestart(String), + Unreachable(eyre::Report), +} + +/// Check if a client can reach the daemon. +pub async fn probe(path: String) -> Probe { + let mut client = match ControlClient::new(path).await { + Ok(client) => client, + Err(err) => return Probe::Unreachable(err), + }; + + match client.status().await { + Ok(status) => { + if daemon_matches_expected(&status.version, status.protocol) { + Probe::Ready(client) + } else { + Probe::NeedsRestart(daemon_mismatch_message(&status.version, status.protocol)) + } + } + Err(err) => Probe::Unreachable(err), + } +} + +// ============================================================================ +// History Client +// ============================================================================ + +#[derive(Debug)] +pub struct HistoryClient { + client: HistoryServiceClient, +} + +pub struct Range { + pub start: OffsetDateTime, + pub end: OffsetDateTime, +} + +// Wrap the grpc client +impl HistoryClient { + #[cfg(unix)] + pub async fn new(path: String) -> Result { + use eyre::Context; + + let log_path = path.clone(); + let channel = Endpoint::try_from("http://atuin_local_daemon:0")? + .connect_with_connector(service_fn(move |_: Uri| { + let path = path.clone(); + + async move { + Ok::<_, std::io::Error>(TokioIo::new(UnixStream::connect(path.clone()).await?)) + } + })) + .await + .wrap_err_with(|| { + format!( + "failed to connect to local atuin daemon at {}. Is it running?", + &log_path + ) + })?; + + let client = HistoryServiceClient::new(channel); + + Ok(Self { client }) + } + + pub async fn start_history(&mut self, h: History) -> Result { + let req = StartHistoryRequest { + command: h.command, + cwd: h.cwd, + hostname: h.hostname, + session: h.session, + timestamp: h.timestamp.unix_timestamp_nanos() as u64, + author: h.author, + intent: h.intent.unwrap_or_default(), + }; + + Ok(self.client.start_history(req).await?.into_inner()) + } + + pub async fn history(&mut self, session: String, range: Option) -> Result> { + let req = HistoryRequest { + session, + range: range.map(|r| generated::history::Range { + start: r.start.unix_timestamp() as u64, + end: r.end.unix_timestamp() as u64, + }), + }; + + let reply = self.client.history(req).await?.into_inner(); + + Ok(reply + .entries + .into_iter() + .map(history_entry_to_history) + .collect()) + } + + pub async fn end_history( + &mut self, + id: String, + duration: u64, + exit: i64, + ) -> Result { + let req = EndHistoryRequest { id, exit, duration }; + + Ok(self.client.end_history(req).await?.into_inner()) + } + + pub async fn tail_history(&mut self) -> Result> { + Ok(self + .client + .tail_history(TailHistoryRequest {}) + .await? + .into_inner()) + } +} + +// ============================================================================ +// Control Client +// ============================================================================ + +/// Client for the Control gRPC service. +#[derive(Debug)] +pub struct ControlClient { + client: ControlServiceClient, +} + +impl ControlClient { + /// Connect to the daemon's control service. + pub async fn new(path: String) -> Result { + let log_path = path.clone(); + let channel = Endpoint::try_from("http://atuin_local_daemon:0")? + .connect_with_connector(service_fn(move |_: Uri| { + let path = path.clone(); + + async move { + Ok::<_, std::io::Error>(TokioIo::new(UnixStream::connect(path.clone()).await?)) + } + })) + .await + .wrap_err_with(|| { + format!( + "failed to connect to local atuin daemon at {}. Is it running?", + &log_path + ) + })?; + + let client = ControlServiceClient::new(channel); + + Ok(Self { client }) + } + + pub async fn paths(&mut self) -> Result { + Ok(self.client.paths(PathsRequest {}).await?.into_inner()) + } + + pub async fn force_sync(&mut self) -> Result { + Ok(self + .client + .force_sync(ForceSyncRequest {}) + .await? + .into_inner()) + } + + pub async fn status(&mut self) -> Result { + Ok(self.client.status(StatusRequest {}).await?.into_inner()) + } +} diff --git a/crates/turtle/src/generated.rs b/crates/turtle/src/generated.rs new file mode 100644 index 00000000..e5e28ac7 --- /dev/null +++ b/crates/turtle/src/generated.rs @@ -0,0 +1,23 @@ +#![expect( + unused_qualifications, + clippy::doc_markdown, + clippy::default_trait_access, + clippy::too_many_lines, + clippy::allow_attributes, + clippy::derive_partial_eq_without_eq, + reason = "All of these lints are triggered by the generated code" +)] + +pub const DAEMON_PROTOCOL_VERSION: u32 = 1; + +/// History module for the daemon gRPC history service. +/// +/// This module contains the proto-generated types for the history gRPC service. +pub mod history { + tonic::include_proto!("history"); +} + +/// Control module for external control. +pub mod control { + tonic::include_proto!("control"); +} diff --git a/crates/turtle/src/history/builder.rs b/crates/turtle/src/history/builder.rs new file mode 100644 index 00000000..7eca0491 --- /dev/null +++ b/crates/turtle/src/history/builder.rs @@ -0,0 +1,78 @@ +use typed_builder::TypedBuilder; + +use super::History; + +/// Builder for a history entry that is loaded from the database. +/// +/// All fields are required, as they are all present in the database. +#[derive(Debug, Clone, TypedBuilder)] +pub struct HistoryFromDb { + id: String, + timestamp: time::OffsetDateTime, + command: String, + cwd: String, + exit: i64, + duration: i64, + session: String, + hostname: String, + author: String, + intent: Option, + deleted_at: Option, +} + +impl From for History { + fn from(from_db: HistoryFromDb) -> Self { + Self { + id: from_db.id.into(), + timestamp: from_db.timestamp, + exit: from_db.exit, + command: from_db.command, + cwd: from_db.cwd, + duration: from_db.duration, + session: from_db.session, + hostname: from_db.hostname, + author: from_db.author, + intent: from_db.intent, + deleted_at: from_db.deleted_at, + } + } +} + +/// Builder for a history entry that is captured via hook and sent to the daemon +/// +/// This builder is similar to Capture, but we just require more information up front. +/// For the old setup, we could just rely on `History::new` to read some of the missing +/// data. This is no longer the case. +#[derive(Debug, Clone, TypedBuilder)] +pub struct HistoryDaemonCapture { + timestamp: time::OffsetDateTime, + #[builder(setter(into))] + command: String, + #[builder(setter(into))] + cwd: String, + #[builder(setter(into))] + session: String, + #[builder(setter(into))] + hostname: String, + #[builder(default, setter(strip_option, into))] + author: Option, + #[builder(default, setter(strip_option, into))] + intent: Option, +} + +impl From for History { + fn from(captured: HistoryDaemonCapture) -> Self { + Self::new( + captured.timestamp, + captured.command, + captured.cwd, + -1, + -1, + Some(captured.session), + Some(captured.hostname), + captured.author, + captured.intent, + None, + ) + } +} diff --git a/crates/turtle/src/history/mod.rs b/crates/turtle/src/history/mod.rs new file mode 100644 index 00000000..10e74d8e --- /dev/null +++ b/crates/turtle/src/history/mod.rs @@ -0,0 +1,300 @@ +use core::fmt::Formatter; +use regex::RegexSet; +use std::env; +use std::fmt::Display; + +use turtle_common::utils::uuid_v7; + +use time::OffsetDateTime; + +use crate::history::secrets::SECRET_PATTERNS_RE; + +pub mod builder; +mod secrets; + +const HISTORY_AUTHOR_ENV: &str = "ATUIN_HISTORY_AUTHOR"; +const HISTORY_INTENT_ENV: &str = "ATUIN_HISTORY_INTENT"; + +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct HistoryId(pub String); + +impl Display for HistoryId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for HistoryId { + fn from(s: String) -> Self { + Self(s) + } +} + +pub(crate) fn get_hostname() -> String { + env::var("ATUIN_HOST_NAME") + .unwrap_or_else(|_| whoami::hostname().unwrap_or_else(|_| "unknown-host".to_string())) +} + +pub(crate) fn get_username() -> String { + env::var("ATUIN_HOST_USER") + .unwrap_or_else(|_| whoami::username().unwrap_or_else(|_| "unknown-user".to_string())) +} + +/// Returns a pair of the hostname and username, separated by a colon. +#[must_use] +pub fn get_host_user() -> String { + format!("{}:{}", get_hostname(), get_username()) +} + +/// Client-side history entry. +/// +/// Client stores data unencrypted, and only encrypts it before sending to the server. +/// +/// To create a new history entry, use one of the builders: +/// - [`History::import()`] to import an entry from the shell history file +/// - [`History::capture()`] to capture an entry via hook +/// - [`History::from_db()`] to create an instance from the database entry +// +// ## Implementation Notes +// +// New fields must be added to `History::{serialize,deserialize}` in a backwards +// compatible way (sensible defaults and careful `nfields` handling). +#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)] +pub struct History { + /// A client-generated ID, used to identify the entry when syncing. + /// + /// Stored as `client_id` in the database. + pub id: HistoryId, + + /// When the command was run. + pub timestamp: OffsetDateTime, + + /// How long the command took to run. + pub duration: i64, + + /// The exit code of the command. + pub exit: i64, + + /// The command that was run. + pub command: String, + + /// The current working directory when the command was run. + pub cwd: String, + + /// The session ID, associated with a terminal session. + pub session: String, + + /// The hostname of the machine the command was run on. + pub hostname: String, + + /// Who wrote this command (human user or automation/agent identity). + pub author: String, + + /// Optional rationale for why the command was executed. + pub intent: Option, + + /// Timestamp, which is set when the entry is deleted, allowing a soft delete. + pub deleted_at: Option, +} + +impl History { + #[must_use] + pub fn author_from_hostname(hostname: &str) -> String { + hostname + .split_once(':') + .map_or_else(|| hostname.to_owned(), |(_, user)| user.to_owned()) + } + + fn normalize_optional_field(field: Option) -> Option { + field.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } + }) + } + + #[expect(clippy::too_many_arguments)] + fn new( + timestamp: OffsetDateTime, + command: String, + cwd: String, + exit: i64, + duration: i64, + session: Option, + hostname: Option, + author: Option, + intent: Option, + deleted_at: Option, + ) -> Self { + let session = session + .or_else(|| env::var("ATUIN_SESSION").ok()) + .unwrap_or_else(|| uuid_v7().as_simple().to_string()); + let hostname = hostname.unwrap_or_else(get_host_user); + let author = Self::normalize_optional_field(author) + .or_else(|| Self::normalize_optional_field(env::var(HISTORY_AUTHOR_ENV).ok())) + .unwrap_or_else(|| Self::author_from_hostname(hostname.as_str())); + let intent = Self::normalize_optional_field(intent) + .or_else(|| Self::normalize_optional_field(env::var(HISTORY_INTENT_ENV).ok())); + + Self { + id: uuid_v7().as_simple().to_string().into(), + timestamp, + command, + cwd, + exit, + duration, + session, + hostname, + author, + intent, + deleted_at, + } + } + + /// Builder for a history entry that is captured via hook, and sent to the daemon. + /// + /// This builder is used only at the `start` step of the hook, + /// so it doesn't have any fields which are known only after + /// the command is finished, such as `exit` or `duration`. + /// + /// It does, however, include information that can usually be inferred. + /// + /// This is because the daemon we are sending a request to lacks the context of the command + /// + /// ## Examples + /// ```rust + /// use crate::aclient::history::History; + /// + /// let history: History = History::daemon() + /// .timestamp(time::OffsetDateTime::now_utc()) + /// .command("ls -la") + /// .cwd("/home/user") + /// .session("018deb6e8287781f9973ef40e0fde76b") + /// .hostname("computer:ellie") + /// .build() + /// .into(); + /// ``` + /// + /// Command without any required info cannot be captured, which is forced at compile time: + /// + /// ```compile_fail + /// use crate::aclient::history::History; + /// + /// // this will not compile because `hostname` is missing + /// let history: History = History::daemon() + /// .timestamp(time::OffsetDateTime::now_utc()) + /// .command("ls -la") + /// .cwd("/home/user") + /// .session("018deb6e8287781f9973ef40e0fde76b") + /// .build() + /// .into(); + /// ``` + pub fn daemon() -> builder::HistoryDaemonCaptureBuilder { + builder::HistoryDaemonCapture::builder() + } + + #[doc(hidden)] + pub fn from_db() -> builder::HistoryFromDbBuilder { + builder::HistoryFromDb::builder() + } + + pub fn should_save(&self, filter: SettingsFilter<'_>) -> bool { + !(self.command.is_empty() + || filter.history.is_match(&self.command) + || filter.cwd.is_match(&self.cwd) + || (filter.secrets && SECRET_PATTERNS_RE.is_match(&self.command))) + } +} + +#[derive(Debug, Copy, Clone)] +pub struct SettingsFilter<'a> { + pub history: &'a RegexSet, + pub cwd: &'a RegexSet, + pub secrets: bool, +} + +#[cfg(test)] +mod tests { + // use regex::RegexSet; + // + // use crate::history::History; + + // // Test that we don't save history where necessary + // #[test] + // fn privacy_test() { + // let settings = Settings { + // cwd_filter: RegexSet::new(["^/supasecret"]).unwrap(), + // history_filter: RegexSet::new(["^psql"]).unwrap(), + // ..Settings::default() + // }; + // + // let normal_command: History = History::daemon() + // .timestamp(time::OffsetDateTime::now_utc()) + // .command("echo foo") + // .cwd("/") + // .build() + // .into(); + // + // let with_space: History = History::daemon() + // .timestamp(time::OffsetDateTime::now_utc()) + // .command(" echo bar") + // .cwd("/") + // .build() + // .into(); + // + // let empty: History = History::daemon() + // .timestamp(time::OffsetDateTime::now_utc()) + // .command("") + // .cwd("/") + // .build() + // .into(); + // + // let stripe_key: History = History::daemon() + // .timestamp(time::OffsetDateTime::now_utc()) + // .command("curl foo.com/bar?key=sk_test_1234567890abcdefghijklmnop") + // .cwd("/") + // .build() + // .into(); + // + // let secret_dir: History = History::daemon() + // .timestamp(time::OffsetDateTime::now_utc()) + // .command("echo ohno") + // .cwd("/supasecret") + // .build() + // .into(); + // + // let with_psql: History = History::daemon() + // .timestamp(time::OffsetDateTime::now_utc()) + // .command("psql") + // .cwd("/supasecret") + // .build() + // .into(); + // + // assert!(normal_command.should_save(&settings)); + // assert!(!with_space.should_save(&settings)); + // assert!(!empty.should_save(&settings)); + // assert!(!stripe_key.should_save(&settings)); + // assert!(!secret_dir.should_save(&settings)); + // assert!(!with_psql.should_save(&settings)); + // } + // + // #[test] + // fn disable_secrets() { + // let settings = Settings { + // secrets_filter: false, + // ..Settings::new().unwrap() + // }; + // + // let stripe_key: History = History::capture() + // .timestamp(time::OffsetDateTime::now_utc()) + // .command("curl foo.com/bar?key=sk_test_1234567890abcdefghijklmnop") + // .cwd("/") + // .build() + // .into(); + // + // assert!(stripe_key.should_save(&settings)); + // } +} diff --git a/crates/turtle/src/history/secrets.rs b/crates/turtle/src/history/secrets.rs new file mode 100644 index 00000000..08d24339 --- /dev/null +++ b/crates/turtle/src/history/secrets.rs @@ -0,0 +1,223 @@ +// This file will probably trigger a lot of scanners. Sorry. + +use regex::RegexSet; +use std::sync::LazyLock; + +#[cfg(test)] +pub(crate) enum TestValue<'a> { + Single(&'a str), + Multiple(&'a [&'a str]), +} + +#[cfg(test)] +type SpType<'a> = &'a [(&'a str, &'a str, TestValue<'a>)]; + +#[cfg(not(test))] +type SpType<'a> = &'a [(&'a str, &'a str)]; + +/// A list of `(name, regex, test)`, where `test` should match against `regex`. +pub(crate) static SECRET_PATTERNS: SpType<'_> = &[ + ( + "AWS Access Key ID", + "A[KS]IA[0-9A-Z]{16}", + #[cfg(test)] + TestValue::Single("AKIAIOSFODNN7EXAMPLE"), + ), + ( + "AWS Secret Access Key env var", + "AWS_SECRET_ACCESS_KEY", + #[cfg(test)] + TestValue::Single("AWS_SECRET_ACCESS_KEY=KEYDATA"), + ), + ( + "AWS Session Token env var", + "AWS_SESSION_TOKEN", + #[cfg(test)] + TestValue::Single("AWS_SESSION_TOKEN=KEYDATA"), + ), + ( + "Microsoft Azure secret access key env var", + "AZURE_.*_KEY", + #[cfg(test)] + TestValue::Single("export AZURE_STORAGE_ACCOUNT_KEY=KEYDATA"), + ), + ( + "Google cloud platform key env var", + "GOOGLE_SERVICE_ACCOUNT_KEY", + #[cfg(test)] + TestValue::Single("export GOOGLE_SERVICE_ACCOUNT_KEY=KEYDATA"), + ), + ( + "Atuin login", + r"atuin\s+login", + #[cfg(test)] + TestValue::Single( + "atuin login -u mycoolusername -p mycoolpassword -k \"lots of random words\"", + ), + ), + ( + "GitHub PAT (old)", + "ghp_[a-zA-Z0-9]{36}", + #[cfg(test)] + TestValue::Single("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"), // legit, I expired it + ), + ( + "GitHub PAT (new)", + "gh1_[A-Za-z0-9]{21}_[A-Za-z0-9]{59}|github_pat_[0-9][A-Za-z0-9]{21}_[A-Za-z0-9]{59}", + #[cfg(test)] + TestValue::Multiple(&[ + "gh1_1234567890abcdefghijk_1234567890abcdefghijklmnopqrstuvwxyz1234567890abcdefghijklm", + "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", // also legit, also expired + ]), + ), + ( + "GitHub OAuth Access Token", + "gho_[A-Za-z0-9]{36}", + #[cfg(test)] + TestValue::Single("gho_1234567890abcdefghijklmnopqrstuvwx000"), // not a real token + ), + ( + "GitHub OAuth Access Token (user)", + "ghu_[A-Za-z0-9]{36}", + #[cfg(test)] + TestValue::Single("ghu_1234567890abcdefghijklmnopqrstuvwx000"), // not a real token + ), + ( + "GitHub App Installation Access Token", + "ghs_[A-Za-z0-9._-]{36,}", + #[cfg(test)] + TestValue::Multiple(&[ + "ghs_1234567890abcdefghijklmnopqrstuvwx000", // not a real token + "ghs_abc-def.ghi_jklMNOP0123456789qrstuv-wxyzABCD", // new token format, fake data + ]), + ), + ( + "GitHub Refresh Token", + "ghr_[A-Za-z0-9]{76}", + #[cfg(test)] + TestValue::Single( + "ghr_1234567890abcdefghijklmnopqrstuvwx1234567890abcdefghijklmnopqrstuvwx1234567890abcdefghijklmnopqrstuvwx", + ), // not a real token + ), + ( + "GitHub App Installation Access Token v1", + "v1\\.[0-9A-Fa-f]{40}", + #[cfg(test)] + TestValue::Single("v1.1234567890abcdef1234567890abcdef12345678"), // not a real token + ), + ( + "GitLab PAT", + "glpat-[a-zA-Z0-9_]{20}", + #[cfg(test)] + TestValue::Single("glpat-RkE_BG5p_bbjML21WSfy"), + ), + ( + "Slack OAuth v2 bot", + "xoxb-[0-9]{11}-[0-9]{11}-[0-9a-zA-Z]{24}", + #[cfg(test)] + TestValue::Single("xoxb-17653672481-19874698323-pdFZKVeTuE8sk7oOcBrzbqgy"), + ), + ( + "Slack OAuth v2 user token", + "xoxp-[0-9]{11}-[0-9]{11}-[0-9a-zA-Z]{24}", + #[cfg(test)] + TestValue::Single("xoxp-17653672481-19874698323-pdFZKVeTuE8sk7oOcBrzbqgy"), + ), + ( + "Slack webhook", + "T[a-zA-Z0-9_]{8}/B[a-zA-Z0-9_]{8}/[a-zA-Z0-9_]{24}", + #[cfg(test)] + TestValue::Single( + "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX", + ), + ), + ( + "Stripe test key", + "sk_test_[0-9a-zA-Z]{24}", + #[cfg(test)] + TestValue::Single("sk_test_1234567890abcdefghijklmnop"), + ), + ( + "Stripe live key", + "sk_live_[0-9a-zA-Z]{24}", + #[cfg(test)] + TestValue::Single("sk_live_1234567890abcdefghijklmnop"), + ), + ( + "Netlify authentication token", + "nf[pcoub]_[0-9a-zA-Z]{36}", + #[cfg(test)] + TestValue::Single("nfp_nBh7BdJxUwyaBBwFzpyD29MMFT6pZ9wq5634"), + ), + ( + "npm token", + "npm_[A-Za-z0-9]{36}", + #[cfg(test)] + TestValue::Single("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"), + ), + ( + "Pulumi personal access token", + "pul-[0-9a-f]{40}", + #[cfg(test)] + TestValue::Single("pul-683c2770662c51d960d72ec27613be7653c5cb26"), + ), +]; + +/// The `regex` expressions from [`SECRET_PATTERNS`] compiled into a `RegexSet`. +pub(crate) static SECRET_PATTERNS_RE: LazyLock = LazyLock::new(|| { + let exprs = SECRET_PATTERNS.iter().map(|f| f.1); + RegexSet::new(exprs).expect("Failed to build secrets regex") +}); + +#[cfg(test)] +mod tests { + use regex::Regex; + + use crate::aclient::secrets::{SECRET_PATTERNS, TestValue}; + + #[test] + fn test_secrets() { + for (name, regex, test) in SECRET_PATTERNS { + let re = + Regex::new(regex).unwrap_or_else(|_| panic!("Failed to compile regex for {name}")); + + match test { + TestValue::Single(test) => { + assert!(re.is_match(test), "{name} test failed!"); + } + TestValue::Multiple(tests) => { + for test_str in tests.iter() { + assert!( + re.is_match(test_str), + "{name} test with value \"{test_str}\" failed!" + ); + } + } + } + } + } + + #[test] + fn test_secrets_embedded() { + for (name, regex, test) in SECRET_PATTERNS { + let re = + Regex::new(regex).unwrap_or_else(|_| panic!("Failed to compile regex for {name}")); + + match test { + TestValue::Single(test) => { + let embedded = format!("some random text {test} some more random text"); + assert!(re.is_match(&embedded), "{name} embedded test failed!"); + } + TestValue::Multiple(tests) => { + for test_str in tests.iter() { + let embedded = format!("some random text {test_str} some more random text"); + assert!( + re.is_match(&embedded), + "{name} embedded test with value \"{test_str}\" failed!" + ); + } + } + } + } + } +} diff --git a/crates/turtle/src/lib.rs b/crates/turtle/src/lib.rs index e69de29b..c78b0475 100644 --- a/crates/turtle/src/lib.rs +++ b/crates/turtle/src/lib.rs @@ -0,0 +1,5 @@ +#![expect(unused_crate_dependencies)] + +pub mod client; +pub mod generated; +pub mod history; -- cgit v1.3.1