From a07cfada2b69925f6d6d4a678a27761c2ee7a4a0 Mon Sep 17 00:00:00 2001 From: Quang Le Date: Fri, 20 Feb 2026 17:44:04 +0900 Subject: [PATCH 1/4] feat(app): implement privkeylock --- Cargo.lock | 1 + crates/app/Cargo.toml | 1 + crates/app/src/lib.rs | 3 + crates/app/src/privkeylock.rs | 270 ++++++++++++++++++++++++++++++++++ 4 files changed, 275 insertions(+) create mode 100644 crates/app/src/privkeylock.rs diff --git a/Cargo.lock b/Cargo.lock index 2fdfe94b..b078a336 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5416,6 +5416,7 @@ dependencies = [ "reqwest 0.13.2", "serde", "serde_json", + "tempfile", "thiserror 2.0.18", "tokio", "tokio-util", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 2ca28c43..cd0ae0e9 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -37,6 +37,7 @@ pluto-crypto.workspace = true pluto-build-proto.workspace = true [dev-dependencies] +tempfile.workspace = true wiremock.workspace = true [lints] diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 7839e743..96c4e461 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -27,3 +27,6 @@ pub mod obolapi; /// Ethereum CL RPC client management. pub mod eth2wrap; + +/// Private key locking service. +pub mod privkeylock; diff --git a/crates/app/src/privkeylock.rs b/crates/app/src/privkeylock.rs new file mode 100644 index 00000000..2c12b262 --- /dev/null +++ b/crates/app/src/privkeylock.rs @@ -0,0 +1,270 @@ +//! Private key locking service. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use tokio_util::sync::CancellationToken; + +/// Duration after which a private key lock file is considered stale. +const STALE_DURATION: Duration = Duration::from_secs(5); + +/// Duration after which the private key lock file is updated. +const UPDATE_PERIOD: Duration = Duration::from_secs(1); + +/// Error type for private key lock operations. +#[derive(Debug, thiserror::Error)] +pub enum PrivKeyLockError { + /// Cannot read the private key lock file. + #[error("cannot read private key lock file: path={path}")] + ReadFile { + /// The underlying I/O error. + source: std::io::Error, + /// Path to the lock file. + path: PathBuf, + }, + + /// Cannot decode the private key lock file content. + #[error("cannot decode private key lock file content: path={path}")] + DecodeFile { + /// The underlying JSON error. + source: serde_json::Error, + /// Path to the lock file. + path: PathBuf, + }, + + /// Another charon instance may be running. + #[error( + "existing private key lock file found, another charon instance may be running on your machine: path={path}, command={command}" + )] + ActiveLock { + /// Path to the lock file. + path: PathBuf, + /// Command stored in the lock file. + command: String, + }, + + /// Cannot marshal the private key lock file. + #[error("cannot marshal private key lock file")] + MarshalFile(#[from] serde_json::Error), + + /// Cannot write the private key lock file. + #[error("cannot write private key lock file: path={path}")] + WriteFile { + /// The underlying I/O error. + source: std::io::Error, + /// Path to the lock file. + path: PathBuf, + }, + + /// Cannot delete the private key lock file. + #[error("deleting private key lock file failed")] + DeleteFile(#[source] std::io::Error), +} + +type Result = std::result::Result; + +/// Metadata stored in the lock file. +#[derive(Debug, Serialize, Deserialize)] +struct Metadata { + command: String, + timestamp: DateTime, +} + +/// Creates or updates the lock file with the latest metadata. +async fn write_file(path: &Path, command: &str, now: DateTime) -> Result<()> { + let meta = Metadata { + command: command.to_owned(), + timestamp: now, + }; + + let bytes = serde_json::to_vec(&meta)?; + + tokio::fs::write(path, bytes) + .await + .map_err(|source| PrivKeyLockError::WriteFile { + source, + path: path.to_path_buf(), + }) +} + +/// Private key locking service. +#[derive(Debug)] +pub struct Service { + command: String, + path: PathBuf, + update_period: Duration, + quit: CancellationToken, +} + +impl Service { + /// Returns a new private key locking service. + /// + /// Errors if a recently-updated private key lock file exists. + pub async fn new(path: impl AsRef, command: impl AsRef) -> Result { + let path = path.as_ref().to_path_buf(); + let command = command.as_ref().to_owned(); + + match tokio::fs::read(&path).await { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // No file, we will create it in run. + } + Err(e) => { + return Err(PrivKeyLockError::ReadFile { + source: e, + path: path.clone(), + }); + } + Ok(content) => { + let meta: Metadata = serde_json::from_slice(&content).map_err(|source| { + PrivKeyLockError::DecodeFile { + source, + path: path.clone(), + } + })?; + + let elapsed = Utc::now().signed_duration_since(meta.timestamp); + let stale = chrono::Duration::from_std(STALE_DURATION) + .expect("STALE_DURATION fits in chrono::Duration"); + + if elapsed <= stale { + return Err(PrivKeyLockError::ActiveLock { + path: path.clone(), + command: meta.command, + }); + } + } + } + + write_file(&path, &command, Utc::now()).await?; + + Ok(Self { + command, + path, + update_period: UPDATE_PERIOD, + quit: CancellationToken::new(), + }) + } + + /// Runs the service, updating the lock file periodically and deleting it on + /// cancellation. + pub async fn run(&self) -> Result<()> { + let mut interval = tokio::time::interval(self.update_period); + // Consume the first immediate tick. + interval.tick().await; + + loop { + tokio::select! { + () = self.quit.cancelled() => { + tokio::fs::remove_file(&self.path) + .await + .map_err(PrivKeyLockError::DeleteFile)?; + + return Ok(()); + } + _ = interval.tick() => { + write_file(&self.path, &self.command, Utc::now()).await?; + } + } + } + } + + /// Signals the service to stop. + /// + /// The caller should await the [`run`](Self::run) future/task to observe + /// completion. + pub fn close(&self) { + self.quit.cancel(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::path::PathBuf; + + #[tokio::test] + async fn test_service() { + let dir = tempfile::tempdir().expect("failed to create temp dir"); + let path: PathBuf = dir.path().join("privkeylocktest"); + + // Create a stale file that is ignored. + let stale_time = + Utc::now() - chrono::Duration::from_std(STALE_DURATION).expect("duration fits"); + write_file(&path, "test", stale_time) + .await + .expect("write stale file"); + + // Create a new service. + let svc = Service::new(path.clone(), "test") + .await + .expect("create service"); + // Speed up the update period for testing. + let svc = Service { + update_period: Duration::from_millis(1), + ..svc + }; + + assert_file_exists(&path).await; + + // Assert a new service can't be created. + let err = Service::new(path.clone(), "test") + .await + .expect_err("should fail"); + let msg = err.to_string(); + assert!( + msg.contains("existing private key lock file found"), + "unexpected error: {msg}" + ); + + // Delete the file so Run will create it again. + tokio::fs::remove_file(&path) + .await + .expect("remove lock file"); + + let run_handle = tokio::spawn({ + let svc_quit = svc.quit.clone(); + let svc_path = svc.path.clone(); + let svc_command = svc.command.clone(); + let svc_update_period = svc.update_period; + async move { + let svc = Service { + command: svc_command, + path: svc_path, + update_period: svc_update_period, + quit: svc_quit, + }; + svc.run().await + } + }); + + assert_file_exists(&path).await; + svc.close(); + + run_handle + .await + .expect("join run task") + .expect("run should succeed"); + + // Assert the file is deleted. + let result = tokio::fs::metadata(&path).await; + assert!(result.is_err(), "file should be deleted"); + } + + async fn assert_file_exists(path: &Path) { + let deadline = tokio::time::Instant::now() + .checked_add(Duration::from_secs(1)) + .expect("deadline overflow"); + loop { + if tokio::fs::metadata(path).await.is_ok() { + return; + } + if tokio::time::Instant::now() >= deadline { + panic!("file did not appear within timeout: {}", path.display()); + } + tokio::time::sleep(Duration::from_millis(1)).await; + } + } +} From 9c186e7c1f12785877d65ce2ec79b6ce22032686 Mon Sep 17 00:00:00 2001 From: Quang Le Date: Tue, 24 Feb 2026 11:17:09 +0900 Subject: [PATCH 2/4] fix: tighten cancellation --- crates/app/src/privkeylock.rs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/crates/app/src/privkeylock.rs b/crates/app/src/privkeylock.rs index 2c12b262..b638c641 100644 --- a/crates/app/src/privkeylock.rs +++ b/crates/app/src/privkeylock.rs @@ -96,6 +96,7 @@ pub struct Service { path: PathBuf, update_period: Duration, quit: CancellationToken, + done: CancellationToken, } impl Service { @@ -144,12 +145,15 @@ impl Service { path, update_period: UPDATE_PERIOD, quit: CancellationToken::new(), + done: CancellationToken::new(), }) } /// Runs the service, updating the lock file periodically and deleting it on /// cancellation. pub async fn run(&self) -> Result<()> { + let _done_guard = self.done.clone().drop_guard(); + let mut interval = tokio::time::interval(self.update_period); // Consume the first immediate tick. interval.tick().await; @@ -157,9 +161,11 @@ impl Service { loop { tokio::select! { () = self.quit.cancelled() => { - tokio::fs::remove_file(&self.path) - .await - .map_err(PrivKeyLockError::DeleteFile)?; + match tokio::fs::remove_file(&self.path).await { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(source) => return Err(PrivKeyLockError::DeleteFile(source)), + } return Ok(()); } @@ -170,12 +176,12 @@ impl Service { } } - /// Signals the service to stop. + /// Closes the service, waiting for [`run`](Self::run) to finish. /// - /// The caller should await the [`run`](Self::run) future/task to observe - /// completion. - pub fn close(&self) { + /// Note: this will wait forever if `run` was never called. + pub async fn close(&self) { self.quit.cancel(); + self.done.cancelled().await; } } @@ -226,6 +232,7 @@ mod tests { let run_handle = tokio::spawn({ let svc_quit = svc.quit.clone(); + let svc_done = svc.done.clone(); let svc_path = svc.path.clone(); let svc_command = svc.command.clone(); let svc_update_period = svc.update_period; @@ -235,13 +242,14 @@ mod tests { path: svc_path, update_period: svc_update_period, quit: svc_quit, + done: svc_done, }; svc.run().await } }); assert_file_exists(&path).await; - svc.close(); + svc.close().await; run_handle .await From 19ac5e6a4ddce6d28b8ee6c45cfa12054b75d9ca Mon Sep 17 00:00:00 2001 From: Quang Le Date: Thu, 26 Feb 2026 09:16:16 +0900 Subject: [PATCH 3/4] fix: using remove chrono usage --- crates/app/src/privkeylock.rs | 98 ++++++++++++----------------------- 1 file changed, 32 insertions(+), 66 deletions(-) diff --git a/crates/app/src/privkeylock.rs b/crates/app/src/privkeylock.rs index b638c641..7abd20d7 100644 --- a/crates/app/src/privkeylock.rs +++ b/crates/app/src/privkeylock.rs @@ -1,9 +1,10 @@ //! Private key locking service. -use std::path::{Path, PathBuf}; -use std::time::Duration; +use std::{ + path::{Path, PathBuf}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; -use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use tokio_util::sync::CancellationToken; @@ -16,23 +17,13 @@ const UPDATE_PERIOD: Duration = Duration::from_secs(1); /// Error type for private key lock operations. #[derive(Debug, thiserror::Error)] pub enum PrivKeyLockError { - /// Cannot read the private key lock file. - #[error("cannot read private key lock file: path={path}")] - ReadFile { - /// The underlying I/O error. - source: std::io::Error, - /// Path to the lock file. - path: PathBuf, - }, + /// I/O error on the private key lock file. + #[error("private key lock file I/O error {0}")] + Io(#[from] std::io::Error), - /// Cannot decode the private key lock file content. - #[error("cannot decode private key lock file content: path={path}")] - DecodeFile { - /// The underlying JSON error. - source: serde_json::Error, - /// Path to the lock file. - path: PathBuf, - }, + /// JSON error on the private key lock file. + #[error("private key lock file JSON error {0}")] + Json(#[from] serde_json::Error), /// Another charon instance may be running. #[error( @@ -44,36 +35,27 @@ pub enum PrivKeyLockError { /// Command stored in the lock file. command: String, }, - - /// Cannot marshal the private key lock file. - #[error("cannot marshal private key lock file")] - MarshalFile(#[from] serde_json::Error), - - /// Cannot write the private key lock file. - #[error("cannot write private key lock file: path={path}")] - WriteFile { - /// The underlying I/O error. - source: std::io::Error, - /// Path to the lock file. - path: PathBuf, - }, - - /// Cannot delete the private key lock file. - #[error("deleting private key lock file failed")] - DeleteFile(#[source] std::io::Error), } type Result = std::result::Result; +/// Returns the current unix timestamp in seconds. +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + /// Metadata stored in the lock file. #[derive(Debug, Serialize, Deserialize)] struct Metadata { command: String, - timestamp: DateTime, + timestamp: u64, } /// Creates or updates the lock file with the latest metadata. -async fn write_file(path: &Path, command: &str, now: DateTime) -> Result<()> { +async fn write_file(path: &Path, command: &str, now: u64) -> Result<()> { let meta = Metadata { command: command.to_owned(), timestamp: now, @@ -81,12 +63,7 @@ async fn write_file(path: &Path, command: &str, now: DateTime) -> Result<() let bytes = serde_json::to_vec(&meta)?; - tokio::fs::write(path, bytes) - .await - .map_err(|source| PrivKeyLockError::WriteFile { - source, - path: path.to_path_buf(), - }) + tokio::fs::write(path, bytes).await.map_err(Into::into) } /// Private key locking service. @@ -112,24 +89,14 @@ impl Service { // No file, we will create it in run. } Err(e) => { - return Err(PrivKeyLockError::ReadFile { - source: e, - path: path.clone(), - }); + return Err(e.into()); } Ok(content) => { - let meta: Metadata = serde_json::from_slice(&content).map_err(|source| { - PrivKeyLockError::DecodeFile { - source, - path: path.clone(), - } - })?; + let meta: Metadata = serde_json::from_slice(&content)?; - let elapsed = Utc::now().signed_duration_since(meta.timestamp); - let stale = chrono::Duration::from_std(STALE_DURATION) - .expect("STALE_DURATION fits in chrono::Duration"); + let elapsed = now_secs().saturating_sub(meta.timestamp); - if elapsed <= stale { + if elapsed <= STALE_DURATION.as_secs() { return Err(PrivKeyLockError::ActiveLock { path: path.clone(), command: meta.command, @@ -138,7 +105,7 @@ impl Service { } } - write_file(&path, &command, Utc::now()).await?; + write_file(&path, &command, now_secs()).await?; Ok(Self { command, @@ -155,8 +122,6 @@ impl Service { let _done_guard = self.done.clone().drop_guard(); let mut interval = tokio::time::interval(self.update_period); - // Consume the first immediate tick. - interval.tick().await; loop { tokio::select! { @@ -164,13 +129,13 @@ impl Service { match tokio::fs::remove_file(&self.path).await { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(source) => return Err(PrivKeyLockError::DeleteFile(source)), + Err(e) => return Err(e.into()), } return Ok(()); } _ = interval.tick() => { - write_file(&self.path, &self.command, Utc::now()).await?; + write_file(&self.path, &self.command, now_secs()).await?; } } } @@ -196,9 +161,10 @@ mod tests { let dir = tempfile::tempdir().expect("failed to create temp dir"); let path: PathBuf = dir.path().join("privkeylocktest"); - // Create a stale file that is ignored. - let stale_time = - Utc::now() - chrono::Duration::from_std(STALE_DURATION).expect("duration fits"); + // Create a stale file that is ignored (one extra second past the threshold). + let stale_time = now_secs() + .saturating_sub(STALE_DURATION.as_secs()) + .saturating_sub(1); write_file(&path, "test", stale_time) .await .expect("write stale file"); From bd6286ce98f15128541f5ff7257e03d1b5a4f54b Mon Sep 17 00:00:00 2001 From: Quang Le Date: Tue, 10 Mar 2026 16:55:18 +0700 Subject: [PATCH 4/4] fix: address comments --- crates/app/src/privkeylock.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/app/src/privkeylock.rs b/crates/app/src/privkeylock.rs index 7abd20d7..1d1c9916 100644 --- a/crates/app/src/privkeylock.rs +++ b/crates/app/src/privkeylock.rs @@ -41,9 +41,10 @@ type Result = std::result::Result; /// Returns the current unix timestamp in seconds. fn now_secs() -> u64 { + #[allow(clippy::unwrap_used, reason = "system clock must be after unix epoch")] SystemTime::now() .duration_since(UNIX_EPOCH) - .unwrap_or_default() + .expect("system time must be after unix epoch") .as_secs() } @@ -122,6 +123,7 @@ impl Service { let _done_guard = self.done.clone().drop_guard(); let mut interval = tokio::time::interval(self.update_period); + interval.tick().await; loop { tokio::select! {