Refactor ChronoSeal daemon for dynamic configurations, connection pooling, custom error handling, and complete tests (v0.5.0)
Rust / build (push) Canceled after 0s
Rust / build (push) Canceled after 0s
This commit is contained in:
1 parent
60aaf0cd96
commit
9e78daeeba
33 files changed
+1192
-168
No files matched your search
+4
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "chronoseal-server"
|
||||
version = "0.2.0"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
@@ -18,6 +18,9 @@ serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
toml = "0.8"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
r2d2 = "0.8"
|
||||
r2d2_sqlite = "0.24"
|
||||
thiserror = "2"
|
||||
tracing = "0.1"
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
|
||||
+19
-9
@@ -1,5 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use crate::session::AppState;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn cleanup_loop(state: Arc<AppState>) {
|
||||
loop {
|
||||
@@ -7,18 +7,28 @@ pub async fn cleanup_loop(state: Arc<AppState>) {
|
||||
|
||||
// Evict expired sessions from SQLite.
|
||||
{
|
||||
let db = state.db.lock().await;
|
||||
let now = crate::storage::current_time_ms();
|
||||
let _ = db.execute(
|
||||
"DELETE FROM sessions WHERE expires_at < ?1",
|
||||
rusqlite::params![now],
|
||||
);
|
||||
if let Ok(conn) = state.db_pool.get() {
|
||||
let now = crate::storage::current_time_ms();
|
||||
let _ = conn.execute(
|
||||
"DELETE FROM sessions WHERE expires_at < ?1",
|
||||
rusqlite::params![now],
|
||||
);
|
||||
} else {
|
||||
tracing::error!("Failed to get database connection from pool for cleanup");
|
||||
}
|
||||
}
|
||||
|
||||
// Evict stale rate-limiter entries to prevent unbounded HashMap growth.
|
||||
{
|
||||
let window_secs = {
|
||||
if let Ok(config) = state.config.read() {
|
||||
config.rate_limit_window_secs
|
||||
} else {
|
||||
10 // fallback default
|
||||
}
|
||||
};
|
||||
let mut rl = state.rate_limiter.lock().await;
|
||||
rl.evict_stale();
|
||||
rl.evict_stale(window_secs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-7
@@ -52,15 +52,21 @@ impl GlobalArgs {
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum Command {
|
||||
/// Run the ChronoSeal daemon.
|
||||
#[command(after_help = "Examples:\n chronoseal run\n chronoseal run --bind 127.0.0.1:3000 --frontend-dir /srv/chronoseal/frontend\n CHRONOSEAL_BIND=0.0.0.0:3000 chronoseal run")]
|
||||
#[command(
|
||||
after_help = "Examples:\n chronoseal run\n chronoseal run --bind 127.0.0.1:3000 --frontend-dir /srv/chronoseal/frontend\n CHRONOSEAL_BIND=0.0.0.0:3000 chronoseal run"
|
||||
)]
|
||||
Run(RunArgs),
|
||||
|
||||
/// Report whether the configured daemon is reachable and which PID file is present.
|
||||
#[command(after_help = "Examples:\n chronoseal status\n chronoseal status --format json\n chronoseal status --pid-file /run/chronoseal.pid")]
|
||||
#[command(
|
||||
after_help = "Examples:\n chronoseal status\n chronoseal status --format json\n chronoseal status --pid-file /run/chronoseal.pid"
|
||||
)]
|
||||
Status(RuntimeArgs),
|
||||
|
||||
/// Perform a daemon health probe.
|
||||
#[command(after_help = "Examples:\n chronoseal health\n chronoseal health --format json\n chronoseal health --bind 127.0.0.1:3000")]
|
||||
#[command(
|
||||
after_help = "Examples:\n chronoseal health\n chronoseal health --format json\n chronoseal health --bind 127.0.0.1:3000"
|
||||
)]
|
||||
Health(RuntimeArgs),
|
||||
|
||||
/// Validate and print effective configuration.
|
||||
@@ -76,7 +82,9 @@ pub enum Command {
|
||||
Version,
|
||||
|
||||
/// Print Prometheus metrics from the running daemon.
|
||||
#[command(after_help = "Examples:\n chronoseal metrics\n chronoseal metrics --bind 127.0.0.1:3000")]
|
||||
#[command(
|
||||
after_help = "Examples:\n chronoseal metrics\n chronoseal metrics --bind 127.0.0.1:3000"
|
||||
)]
|
||||
Metrics(RuntimeArgs),
|
||||
|
||||
/// Print service statistics from the running daemon.
|
||||
@@ -84,7 +92,9 @@ pub enum Command {
|
||||
Stats(RuntimeArgs),
|
||||
|
||||
/// Generate shell completions.
|
||||
#[command(after_help = "Examples:\n chronoseal completion bash\n chronoseal completion zsh > ~/.zfunc/_chronoseal")]
|
||||
#[command(
|
||||
after_help = "Examples:\n chronoseal completion bash\n chronoseal completion zsh > ~/.zfunc/_chronoseal"
|
||||
)]
|
||||
Completion { shell: clap_complete::Shell },
|
||||
}
|
||||
|
||||
@@ -120,13 +130,17 @@ pub struct RuntimeArgs {
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum ConfigCommand {
|
||||
/// Validate configuration and print the effective values.
|
||||
#[command(after_help = "Examples:\n chronoseal config check\n chronoseal config check --config /etc/chronoseal/config.toml\n chronoseal config check --output json")]
|
||||
#[command(
|
||||
after_help = "Examples:\n chronoseal config check\n chronoseal config check --config /etc/chronoseal/config.toml\n chronoseal config check --output json"
|
||||
)]
|
||||
Check(RuntimeArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum GenerateCommand {
|
||||
/// Generate an Ed25519 keypair as hex-encoded JSON/YAML/text.
|
||||
#[command(after_help = "Examples:\n chronoseal generate keypair\n chronoseal generate keypair --format json")]
|
||||
#[command(
|
||||
after_help = "Examples:\n chronoseal generate keypair\n chronoseal generate keypair --format json"
|
||||
)]
|
||||
Keypair,
|
||||
}
|
||||
+83
-2
@@ -14,6 +14,16 @@ pub struct Config {
|
||||
pub db_path: PathBuf,
|
||||
pub frontend_dir: PathBuf,
|
||||
pub log_file: Option<PathBuf>,
|
||||
pub heartbeat_min_interval_ms: u64,
|
||||
pub heartbeat_max_interval_ms: u64,
|
||||
pub expiration_minutes: i64,
|
||||
pub rate_limit_count: u32,
|
||||
pub rate_limit_window_secs: u64,
|
||||
pub max_timestamp_drift_ms: i64,
|
||||
pub min_mouse_total_dist: f64,
|
||||
pub max_mouse_avg_speed: f64,
|
||||
pub min_pause_count: u32,
|
||||
pub require_mouse_activity: bool,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
@@ -24,6 +34,16 @@ impl Default for Config {
|
||||
db_path: default_state_dir().join("chronoseal.sqlite"),
|
||||
frontend_dir: PathBuf::from("/usr/share/chronoseal/frontend"),
|
||||
log_file: None,
|
||||
heartbeat_min_interval_ms: 12_000,
|
||||
heartbeat_max_interval_ms: 25_000,
|
||||
expiration_minutes: 30,
|
||||
rate_limit_count: 5,
|
||||
rate_limit_window_secs: 10,
|
||||
max_timestamp_drift_ms: 30_000,
|
||||
min_mouse_total_dist: 10.0,
|
||||
max_mouse_avg_speed: 2.0,
|
||||
min_pause_count: 1,
|
||||
require_mouse_activity: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,7 +52,10 @@ impl Config {
|
||||
pub fn load(config_path: Option<&Path>) -> Result<Self, ConfigError> {
|
||||
let mut config = Self::default();
|
||||
|
||||
if let Some(path) = config_path.map(Path::to_path_buf).or_else(discover_config_path) {
|
||||
if let Some(path) = config_path
|
||||
.map(Path::to_path_buf)
|
||||
.or_else(discover_config_path)
|
||||
{
|
||||
let raw = fs::read_to_string(&path).map_err(|source| ConfigError::Read {
|
||||
path: path.clone(),
|
||||
source,
|
||||
@@ -96,6 +119,56 @@ impl Config {
|
||||
if let Ok(value) = env::var("CHRONOSEAL_LOG_FILE") {
|
||||
self.log_file = Some(PathBuf::from(value));
|
||||
}
|
||||
if let Ok(value) = env::var("CHRONOSEAL_HEARTBEAT_MIN_INTERVAL_MS") {
|
||||
if let Ok(val) = value.parse() {
|
||||
self.heartbeat_min_interval_ms = val;
|
||||
}
|
||||
}
|
||||
if let Ok(value) = env::var("CHRONOSEAL_HEARTBEAT_MAX_INTERVAL_MS") {
|
||||
if let Ok(val) = value.parse() {
|
||||
self.heartbeat_max_interval_ms = val;
|
||||
}
|
||||
}
|
||||
if let Ok(value) = env::var("CHRONOSEAL_EXPIRATION_MINUTES") {
|
||||
if let Ok(val) = value.parse() {
|
||||
self.expiration_minutes = val;
|
||||
}
|
||||
}
|
||||
if let Ok(value) = env::var("CHRONOSEAL_RATE_LIMIT_COUNT") {
|
||||
if let Ok(val) = value.parse() {
|
||||
self.rate_limit_count = val;
|
||||
}
|
||||
}
|
||||
if let Ok(value) = env::var("CHRONOSEAL_RATE_LIMIT_WINDOW_SECS") {
|
||||
if let Ok(val) = value.parse() {
|
||||
self.rate_limit_window_secs = val;
|
||||
}
|
||||
}
|
||||
if let Ok(value) = env::var("CHRONOSEAL_MAX_TIMESTAMP_DRIFT_MS") {
|
||||
if let Ok(val) = value.parse() {
|
||||
self.max_timestamp_drift_ms = val;
|
||||
}
|
||||
}
|
||||
if let Ok(value) = env::var("CHRONOSEAL_MIN_MOUSE_TOTAL_DIST") {
|
||||
if let Ok(val) = value.parse() {
|
||||
self.min_mouse_total_dist = val;
|
||||
}
|
||||
}
|
||||
if let Ok(value) = env::var("CHRONOSEAL_MAX_MOUSE_AVG_SPEED") {
|
||||
if let Ok(val) = value.parse() {
|
||||
self.max_mouse_avg_speed = val;
|
||||
}
|
||||
}
|
||||
if let Ok(value) = env::var("CHRONOSEAL_MIN_PAUSE_COUNT") {
|
||||
if let Ok(val) = value.parse() {
|
||||
self.min_pause_count = val;
|
||||
}
|
||||
}
|
||||
if let Ok(value) = env::var("CHRONOSEAL_REQUIRE_MOUSE_ACTIVITY") {
|
||||
if let Ok(val) = value.parse() {
|
||||
self.require_mouse_activity = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +195,9 @@ impl std::fmt::Display for ConfigError {
|
||||
Self::Parse { path, source } => {
|
||||
write!(f, "failed to parse {} as TOML: {source}", path.display())
|
||||
}
|
||||
Self::InvalidBind { bind, source } => write!(f, "invalid bind address {bind}: {source}"),
|
||||
Self::InvalidBind { bind, source } => {
|
||||
write!(f, "invalid bind address {bind}: {source}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,6 +205,12 @@ impl std::fmt::Display for ConfigError {
|
||||
impl std::error::Error for ConfigError {}
|
||||
|
||||
fn discover_config_path() -> Option<PathBuf> {
|
||||
if let Ok(path) = env::var("CHRONOSEAL_CONFIG") {
|
||||
let p = PathBuf::from(path);
|
||||
if p.is_file() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
user_config_candidates()
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.is_file())
|
||||
|
||||
@@ -6,9 +6,7 @@ pub fn verify_signature(
|
||||
pub_key_bytes: &[u8],
|
||||
req: &HeartbeatRequest,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let pk = VerifyingKey::from_bytes(
|
||||
&pub_key_bytes.try_into().map_err(|_| "invalid pubkey")?,
|
||||
)?;
|
||||
let pk = VerifyingKey::from_bytes(&pub_key_bytes.try_into().map_err(|_| "invalid pubkey")?)?;
|
||||
let sig_bytes = hex::decode(&req.signature)?;
|
||||
let sig = Signature::from_slice(&sig_bytes)?;
|
||||
|
||||
@@ -26,4 +24,4 @@ pub fn verify_signature(
|
||||
|
||||
pk.verify_strict(message.as_bytes(), &sig)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum SessionError {
|
||||
#[error("Hex decoding error: {0}")]
|
||||
Hex(#[from] hex::FromHexError),
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] rusqlite::Error),
|
||||
|
||||
#[error("R2D2 pool error: {0}")]
|
||||
Pool(#[from] r2d2::Error),
|
||||
|
||||
#[error("Invalid public key length")]
|
||||
InvalidPublicKeyLength,
|
||||
}
|
||||
|
||||
impl IntoResponse for SessionError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, error_message) = match self {
|
||||
SessionError::InvalidPublicKeyLength => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
_ => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Internal server error".to_string(),
|
||||
),
|
||||
};
|
||||
let body = Json(json!({
|
||||
"error": error_message
|
||||
}));
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum VerificationError {
|
||||
#[error("Session not found")]
|
||||
SessionNotFound,
|
||||
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] rusqlite::Error),
|
||||
|
||||
#[error("Hex decoding error: {0}")]
|
||||
Hex(#[from] hex::FromHexError),
|
||||
|
||||
#[error("Signature verification error: {0}")]
|
||||
Signature(String),
|
||||
|
||||
#[error("Session has expired")]
|
||||
Expired,
|
||||
|
||||
#[error("Chain is broken")]
|
||||
ChainBroken,
|
||||
|
||||
#[error("Timestamp drift exceeded threshold")]
|
||||
TimestampDrift,
|
||||
|
||||
#[error("Trust criteria failed: {0}")]
|
||||
TrustFailed(String),
|
||||
|
||||
#[error("Fingerprint validation failed: {0}")]
|
||||
FingerprintFailed(String),
|
||||
}
|
||||
@@ -2,9 +2,15 @@ use shared::protocol::Fingerprint;
|
||||
|
||||
pub fn validate(fp: &Fingerprint) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let ar: f64 = fp.aspect_ratio.parse().map_err(|_| "ar")?;
|
||||
if ar < 0.5 || ar > 3.0 { return Err("aspect ratio".into()); }
|
||||
if !(0.5..=3.0).contains(&ar) {
|
||||
return Err("aspect ratio".into());
|
||||
}
|
||||
let dpr: f64 = fp.device_pixel_ratio.parse().map_err(|_| "dpr")?;
|
||||
if dpr <= 0.0 || dpr > 5.0 { return Err("dpr".into()); }
|
||||
if fp.hardware_concurrency == 0 { return Err("hw".into()); }
|
||||
if dpr <= 0.0 || dpr > 5.0 {
|
||||
return Err("dpr".into());
|
||||
}
|
||||
if fp.hardware_concurrency == 0 {
|
||||
return Err("hw".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ mod cleanup;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod crypto;
|
||||
mod errors;
|
||||
mod fingerprint;
|
||||
mod middleware;
|
||||
mod output;
|
||||
@@ -29,6 +30,9 @@ async fn main() {
|
||||
|
||||
async fn try_main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cli = Cli::parse();
|
||||
if let Some(config_path) = cli.globals.config.as_deref() {
|
||||
std::env::set_var("CHRONOSEAL_CONFIG", config_path);
|
||||
}
|
||||
let log_filter = cli.globals.log.as_deref().unwrap_or("info");
|
||||
let log_file = log_file_for_command(&cli);
|
||||
let _log_guard = init_logging(log_filter, log_file)?;
|
||||
|
||||
@@ -8,4 +8,4 @@ pub async fn log_request(req: Request, next: Next) -> Response {
|
||||
let response = next.run(req).await;
|
||||
tracing::info!("{} {} -> {}", method, uri, response.status());
|
||||
response
|
||||
}
|
||||
}
|
||||
+45
-11
@@ -3,22 +3,22 @@ use std::time::Instant;
|
||||
|
||||
pub struct RateLimiter {
|
||||
buckets: HashMap<String, (u32, Instant)>,
|
||||
limit: u32,
|
||||
window_secs: u64,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
pub fn new(limit: u32, window_secs: u64) -> Self {
|
||||
Self { buckets: HashMap::new(), limit, window_secs }
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
buckets: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check(&mut self, key: &str) -> bool {
|
||||
pub fn check(&mut self, key: &str, limit: u32, window_secs: u64) -> bool {
|
||||
let now = Instant::now();
|
||||
let entry = self.buckets.entry(key.to_string()).or_insert((0, now));
|
||||
if now.duration_since(entry.1).as_secs() >= self.window_secs {
|
||||
if now.duration_since(entry.1).as_secs() >= window_secs {
|
||||
*entry = (1, now);
|
||||
true
|
||||
} else if entry.0 >= self.limit {
|
||||
} else if entry.0 >= limit {
|
||||
false
|
||||
} else {
|
||||
entry.0 += 1;
|
||||
@@ -28,10 +28,44 @@ impl RateLimiter {
|
||||
|
||||
/// Remove entries whose rate-limit window has fully elapsed.
|
||||
/// Call this periodically (e.g. from the cleanup loop) to bound memory usage.
|
||||
pub fn evict_stale(&mut self) {
|
||||
let window = self.window_secs;
|
||||
pub fn evict_stale(&mut self, window_secs: u64) {
|
||||
let now = Instant::now();
|
||||
self.buckets
|
||||
.retain(|_, (_, ts)| now.duration_since(*ts).as_secs() < window);
|
||||
.retain(|_, (_, ts)| now.duration_since(*ts).as_secs() < window_secs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn test_rate_limiter() {
|
||||
let mut rl = RateLimiter::new();
|
||||
// Limit of 2 requests per 1 second window
|
||||
assert!(rl.check("user1", 2, 1));
|
||||
assert!(rl.check("user1", 2, 1));
|
||||
assert!(!rl.check("user1", 2, 1)); // 3rd fails
|
||||
|
||||
assert!(rl.check("user2", 2, 1)); // different key succeeds
|
||||
|
||||
thread::sleep(Duration::from_millis(1100));
|
||||
assert!(rl.check("user1", 2, 1)); // succeeds after time window
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limiter_eviction() {
|
||||
let mut rl = RateLimiter::new();
|
||||
assert!(rl.check("user1", 1, 1));
|
||||
assert_eq!(rl.buckets.len(), 1);
|
||||
|
||||
rl.evict_stale(1);
|
||||
assert_eq!(rl.buckets.len(), 1); // not stale yet
|
||||
|
||||
thread::sleep(Duration::from_millis(1100));
|
||||
rl.evict_stale(1);
|
||||
assert_eq!(rl.buckets.len(), 0); // evicted
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use std::sync::Arc;
|
||||
use shared::protocol::{HeartbeatRequest, HeartbeatResponse};
|
||||
use crate::session::AppState;
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use shared::protocol::{HeartbeatRequest, HeartbeatResponse};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
@@ -9,22 +9,63 @@ pub async fn handler(
|
||||
) -> (StatusCode, Json<HeartbeatResponse>) {
|
||||
// Rate limiting
|
||||
{
|
||||
let (limit, window_secs) = {
|
||||
if let Ok(cfg) = state.config.read() {
|
||||
(cfg.rate_limit_count, cfg.rate_limit_window_secs)
|
||||
} else {
|
||||
(5, 10)
|
||||
}
|
||||
};
|
||||
let mut rl = state.rate_limiter.lock().await;
|
||||
if !rl.check(&payload.session_id) {
|
||||
if !rl.check(&payload.session_id, limit, window_secs) {
|
||||
tracing::debug!("Rate limit hit: {}", payload.session_id);
|
||||
return (StatusCode::OK, Json(HeartbeatResponse { status: "ok".into(), next_salt: None }));
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(HeartbeatResponse {
|
||||
status: "ok".into(),
|
||||
next_salt: None,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let db = state.db.lock().await;
|
||||
match crate::session::verify_heartbeat(&db, &payload) {
|
||||
let config = {
|
||||
if let Ok(cfg) = state.config.read() {
|
||||
cfg.clone()
|
||||
} else {
|
||||
crate::config::Config::default()
|
||||
}
|
||||
};
|
||||
let conn = match state.db_pool.get() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("Db pool error: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(HeartbeatResponse {
|
||||
status: "error".into(),
|
||||
next_salt: None,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
match crate::session::verify_heartbeat(&conn, &config, &payload) {
|
||||
Ok(next_salt) => (
|
||||
StatusCode::OK,
|
||||
Json(HeartbeatResponse { status: "ok".into(), next_salt: Some(next_salt) }),
|
||||
Json(HeartbeatResponse {
|
||||
status: "ok".into(),
|
||||
next_salt: Some(next_salt),
|
||||
}),
|
||||
),
|
||||
Err(e) => {
|
||||
tracing::warn!("Heartbeat failed for {}: {}", payload.session_id, e);
|
||||
(StatusCode::OK, Json(HeartbeatResponse { status: "ok".into(), next_salt: None }))
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(HeartbeatResponse {
|
||||
status: "ok".into(),
|
||||
next_salt: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-12
@@ -1,17 +1,21 @@
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use std::sync::Arc;
|
||||
use shared::protocol::{InitRequest, InitResponse};
|
||||
use crate::errors::SessionError;
|
||||
use crate::session::AppState;
|
||||
use axum::{extract::State, Json};
|
||||
use shared::protocol::{InitRequest, InitResponse};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(payload): Json<InitRequest>,
|
||||
) -> Result<Json<InitResponse>, (StatusCode, String)> {
|
||||
let db = state.db.lock().await;
|
||||
crate::session::create_session(&db, &payload.public_key)
|
||||
.map(Json)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Init error: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Internal".into())
|
||||
})
|
||||
}
|
||||
) -> Result<Json<InitResponse>, SessionError> {
|
||||
let config = {
|
||||
if let Ok(cfg) = state.config.read() {
|
||||
cfg.clone()
|
||||
} else {
|
||||
crate::config::Config::default()
|
||||
}
|
||||
};
|
||||
let conn = state.db_pool.get()?;
|
||||
let resp = crate::session::create_session(&conn, &config, &payload.public_key)?;
|
||||
Ok(Json(resp))
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
pub mod init;
|
||||
pub mod heartbeat;
|
||||
pub mod init;
|
||||
+40
-22
@@ -41,7 +41,9 @@ pub struct StatusReport {
|
||||
|
||||
impl TextOutput for StatusReport {
|
||||
fn to_text(&self) -> String {
|
||||
let pid = self.pid.map_or_else(|| "unknown".to_string(), |pid| pid.to_string());
|
||||
let pid = self
|
||||
.pid
|
||||
.map_or_else(|| "unknown".to_string(), |pid| pid.to_string());
|
||||
format!(
|
||||
"running={}\nhealthy={}\nbind={}\npid_file={}\npid={}",
|
||||
self.running, self.healthy, self.bind, self.pid_file, pid
|
||||
@@ -106,13 +108,11 @@ impl TextOutput for StoreStats {
|
||||
pub async fn run_daemon(config: Config) -> Result<(), Box<dyn std::error::Error>> {
|
||||
install_pid_file(&config.pid_file)?;
|
||||
|
||||
let conn = storage::init_db(&config.db_path)?;
|
||||
let db_pool = storage::init_pool(&config.db_path)?;
|
||||
let state = Arc::new(session::AppState {
|
||||
db: Mutex::new(conn),
|
||||
rate_limiter: Mutex::new(RateLimiter::new(
|
||||
shared::constants::RATE_LIMIT_COUNT,
|
||||
shared::constants::RATE_LIMIT_WINDOW_SECS,
|
||||
)),
|
||||
db_pool,
|
||||
rate_limiter: Mutex::new(RateLimiter::new()),
|
||||
config: std::sync::RwLock::new(config.clone()),
|
||||
});
|
||||
|
||||
let bg_state = state.clone();
|
||||
@@ -124,16 +124,19 @@ pub async fn run_daemon(config: Config) -> Result<(), Box<dyn std::error::Error>
|
||||
.route("/health", get(health_handler))
|
||||
.route("/metrics", get(metrics_handler))
|
||||
.route("/stats", get(stats_handler))
|
||||
.nest_service("/", tower_http::services::ServeDir::new(&config.frontend_dir))
|
||||
.nest_service(
|
||||
"/",
|
||||
tower_http::services::ServeDir::new(&config.frontend_dir),
|
||||
)
|
||||
.layer(tower_http::cors::CorsLayer::permissive())
|
||||
.layer(axum::middleware::from_fn(crate::middleware::log_request))
|
||||
.with_state(state);
|
||||
.with_state(state.clone());
|
||||
|
||||
let addr: SocketAddr = config.bind.parse()?;
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
info!(bind = %config.bind, "chronoseal daemon started");
|
||||
|
||||
let shutdown = signal_task(config.clone());
|
||||
let shutdown = signal_task(state.clone());
|
||||
let result = axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown)
|
||||
.await;
|
||||
@@ -199,13 +202,19 @@ pub fn version() -> VersionReport {
|
||||
}
|
||||
|
||||
async fn health_handler() -> impl IntoResponse {
|
||||
(StatusCode::OK, Json(serde_json::json!({ "status": "healthy" })))
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "status": "healthy" })),
|
||||
)
|
||||
}
|
||||
|
||||
async fn stats_handler(
|
||||
axum::extract::State(state): axum::extract::State<Arc<session::AppState>>,
|
||||
) -> Result<Json<StoreStats>, (StatusCode, String)> {
|
||||
let db = state.db.lock().await;
|
||||
let db = state
|
||||
.db_pool
|
||||
.get()
|
||||
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
|
||||
storage::stats(&db)
|
||||
.map(Json)
|
||||
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))
|
||||
@@ -214,7 +223,10 @@ async fn stats_handler(
|
||||
async fn metrics_handler(
|
||||
axum::extract::State(state): axum::extract::State<Arc<session::AppState>>,
|
||||
) -> Result<String, (StatusCode, String)> {
|
||||
let db = state.db.lock().await;
|
||||
let db = state
|
||||
.db_pool
|
||||
.get()
|
||||
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
|
||||
storage::stats(&db)
|
||||
.map(|stats| {
|
||||
format!(
|
||||
@@ -225,7 +237,7 @@ async fn metrics_handler(
|
||||
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))
|
||||
}
|
||||
|
||||
async fn signal_task(config: Config) {
|
||||
async fn signal_task(state: Arc<session::AppState>) {
|
||||
let shutdown = Arc::new(Notify::new());
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -248,19 +260,23 @@ async fn signal_task(config: Config) {
|
||||
}
|
||||
});
|
||||
|
||||
let hup_config = config.clone();
|
||||
let state_for_hup = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut sighup = signal(SignalKind::hangup()).expect("install SIGHUP handler");
|
||||
while sighup.recv().await.is_some() {
|
||||
match Config::load(None) {
|
||||
Ok(reloaded) => info!(
|
||||
bind = %reloaded.bind,
|
||||
db_path = %reloaded.db_path.display(),
|
||||
"received SIGHUP; configuration reloaded"
|
||||
),
|
||||
Ok(reloaded) => {
|
||||
info!(
|
||||
bind = %reloaded.bind,
|
||||
db_path = %reloaded.db_path.display(),
|
||||
"received SIGHUP; configuration reloaded"
|
||||
);
|
||||
if let Ok(mut config_write) = state_for_hup.config.write() {
|
||||
*config_write = reloaded;
|
||||
}
|
||||
}
|
||||
Err(err) => warn!(error = %err, "received SIGHUP; configuration reload failed"),
|
||||
}
|
||||
let _ = &hup_config;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -312,7 +328,9 @@ fn read_pid(path: &Path) -> Option<u32> {
|
||||
fn http_get(bind: &str, path: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let mut stream = TcpStream::connect_timeout(&bind.parse()?, Duration::from_secs(2))?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
|
||||
stream.write_all(format!("GET {path} HTTP/1.1\r\nHost: chronoseal\r\nConnection: close\r\n\r\n").as_bytes())?;
|
||||
stream.write_all(
|
||||
format!("GET {path} HTTP/1.1\r\nHost: chronoseal\r\nConnection: close\r\n\r\n").as_bytes(),
|
||||
)?;
|
||||
|
||||
let mut response = String::new();
|
||||
stream.read_to_string(&mut response)?;
|
||||
|
||||
+132
-16
@@ -1,24 +1,26 @@
|
||||
pub struct AppState {
|
||||
pub db: tokio::sync::Mutex<rusqlite::Connection>,
|
||||
pub db_pool: crate::storage::DbPool,
|
||||
pub rate_limiter: tokio::sync::Mutex<crate::ratelimit::RateLimiter>,
|
||||
pub config: std::sync::RwLock<crate::config::Config>,
|
||||
}
|
||||
|
||||
use crate::{crypto, fingerprint, storage, trust, vm};
|
||||
use rusqlite::params;
|
||||
use shared::protocol::{HeartbeatRequest, InitResponse};
|
||||
use crate::{crypto, trust, fingerprint, vm, storage};
|
||||
|
||||
pub fn create_session(
|
||||
conn: &rusqlite::Connection,
|
||||
config: &crate::config::Config,
|
||||
pub_key_hex: &str,
|
||||
) -> Result<InitResponse, Box<dyn std::error::Error>> {
|
||||
) -> Result<InitResponse, crate::errors::SessionError> {
|
||||
let pub_key = hex::decode(pub_key_hex)?;
|
||||
if pub_key.len() != shared::constants::SESSION_ID_LEN {
|
||||
return Err("invalid pubkey len".into());
|
||||
return Err(crate::errors::SessionError::InvalidPublicKeyLength);
|
||||
}
|
||||
let session_id = hex::encode(rand::random::<[u8; shared::constants::SESSION_ID_LEN]>());
|
||||
let salt = rand::random::<[u8; shared::constants::SALT_LEN]>();
|
||||
let now = storage::current_time_ms();
|
||||
let expires_at = now + (shared::constants::EXPIRATION_MINUTES as u64) * 60 * 1000;
|
||||
let expires_at = now + (config.expiration_minutes as u64) * 60 * 1000;
|
||||
|
||||
let initial_hash = shared::hashing::initial_hash(&session_id, &pub_key, &salt);
|
||||
|
||||
@@ -37,43 +39,56 @@ pub fn create_session(
|
||||
opcodes_b64,
|
||||
initial_hash: hex::encode(&initial_hash),
|
||||
expires_at,
|
||||
heartbeat_min_interval_ms: config.heartbeat_min_interval_ms,
|
||||
heartbeat_max_interval_ms: config.heartbeat_max_interval_ms,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn verify_heartbeat(
|
||||
conn: &rusqlite::Connection,
|
||||
config: &crate::config::Config,
|
||||
req: &HeartbeatRequest,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
) -> Result<String, crate::errors::VerificationError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT public_key, salt, last_hash, expires_at FROM sessions WHERE session_id = ?1",
|
||||
)?;
|
||||
let (pub_key, salt, stored_last_hash, expires_at): (Vec<u8>, Vec<u8>, Vec<u8>, u64) =
|
||||
stmt.query_row(params![req.session_id], |row| {
|
||||
let (pub_key, salt, stored_last_hash, expires_at): (Vec<u8>, Vec<u8>, Vec<u8>, u64) = stmt
|
||||
.query_row(params![req.session_id], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
||||
})
|
||||
.map_err(|e| {
|
||||
if matches!(e, rusqlite::Error::QueryReturnedNoRows) {
|
||||
crate::errors::VerificationError::SessionNotFound
|
||||
} else {
|
||||
crate::errors::VerificationError::Database(e)
|
||||
}
|
||||
})?;
|
||||
|
||||
let now = storage::current_time_ms();
|
||||
if now > expires_at {
|
||||
return Err("expired".into());
|
||||
return Err(crate::errors::VerificationError::Expired);
|
||||
}
|
||||
|
||||
// 1. Verify signature
|
||||
crypto::verify_signature(&pub_key, req)?;
|
||||
crypto::verify_signature(&pub_key, req)
|
||||
.map_err(|e| crate::errors::VerificationError::Signature(e.to_string()))?;
|
||||
|
||||
// 2. Check chain continuity
|
||||
if stored_last_hash != hex::decode(&req.prev_hash)? {
|
||||
return Err("chain broken".into());
|
||||
return Err(crate::errors::VerificationError::ChainBroken);
|
||||
}
|
||||
|
||||
// 3. Time window
|
||||
let diff = (now as i64) - (req.timestamp as i64);
|
||||
if diff.abs() > shared::constants::MAX_TIMESTAMP_DRIFT_MS {
|
||||
return Err("timestamp drift".into());
|
||||
if diff.abs() > config.max_timestamp_drift_ms {
|
||||
return Err(crate::errors::VerificationError::TimestampDrift);
|
||||
}
|
||||
|
||||
// 4. Trusted mouse & fingerprint
|
||||
trust::validate_mouse(&req.entropy_data)?;
|
||||
fingerprint::validate(&req.fingerprint)?;
|
||||
trust::validate_mouse(&req.entropy_data, config)
|
||||
.map_err(|e| crate::errors::VerificationError::TrustFailed(e.to_string()))?;
|
||||
fingerprint::validate(&req.fingerprint)
|
||||
.map_err(|e| crate::errors::VerificationError::FingerprintFailed(e.to_string()))?;
|
||||
|
||||
// 5. Compute new hash
|
||||
let prev_hash_bytes = hex::decode(&req.prev_hash)?;
|
||||
@@ -95,4 +110,105 @@ pub fn verify_heartbeat(
|
||||
)?;
|
||||
|
||||
Ok(next_salt_hex)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
use shared::protocol::{EntropyData, Fingerprint, HeartbeatRequest, StackState};
|
||||
use std::path::Path;
|
||||
|
||||
fn sign_request(sk: &SigningKey, req: &mut HeartbeatRequest) {
|
||||
let mut payload: std::collections::BTreeMap<&str, serde_json::Value> =
|
||||
std::collections::BTreeMap::new();
|
||||
payload.insert(
|
||||
"entropyData",
|
||||
serde_json::to_value(&req.entropy_data).unwrap(),
|
||||
);
|
||||
payload.insert(
|
||||
"fingerprint",
|
||||
serde_json::to_value(&req.fingerprint).unwrap(),
|
||||
);
|
||||
payload.insert("prevHash", serde_json::json!(req.prev_hash));
|
||||
payload.insert("sessionId", serde_json::json!(req.session_id));
|
||||
payload.insert(
|
||||
"stackState",
|
||||
serde_json::to_value(&req.stack_state).unwrap(),
|
||||
);
|
||||
payload.insert("timestamp", serde_json::json!(req.timestamp));
|
||||
let message = serde_json::to_string(&payload).unwrap();
|
||||
let sig = sk.sign(message.as_bytes());
|
||||
req.signature = hex::encode(sig.to_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_lifecycle_and_verification() {
|
||||
let pool = storage::init_pool(Path::new(":memory:")).unwrap();
|
||||
let conn = pool.get().unwrap();
|
||||
|
||||
let config = crate::config::Config {
|
||||
expiration_minutes: 30,
|
||||
max_timestamp_drift_ms: 30000,
|
||||
min_mouse_total_dist: 10.0,
|
||||
max_mouse_avg_speed: 2.0,
|
||||
min_pause_count: 1,
|
||||
require_mouse_activity: false, // simpler for tests
|
||||
..crate::config::Config::default()
|
||||
};
|
||||
|
||||
// Generate Ed25519 keypair
|
||||
let mut rng = rand::thread_rng();
|
||||
let sk = SigningKey::generate(&mut rng);
|
||||
let pk = sk.verifying_key();
|
||||
let pub_key_hex = hex::encode(pk.to_bytes());
|
||||
|
||||
// 1. Create Session
|
||||
let start_time = storage::current_time_ms();
|
||||
let init_resp = create_session(&conn, &config, &pub_key_hex).unwrap();
|
||||
assert!(init_resp.expires_at >= start_time + 30 * 60 * 1000);
|
||||
assert!(init_resp.expires_at <= storage::current_time_ms() + 30 * 60 * 1000);
|
||||
|
||||
// Verify stats
|
||||
let stats = storage::stats(&conn).unwrap();
|
||||
assert_eq!(stats.sessions, 1);
|
||||
assert_eq!(stats.expired_sessions, 0);
|
||||
|
||||
// 2. Heartbeat Verification
|
||||
let now = storage::current_time_ms();
|
||||
let entropy_data = EntropyData { events: vec![] };
|
||||
let stack_state = StackState {
|
||||
stack: vec![42],
|
||||
ip: 5,
|
||||
};
|
||||
let fingerprint = Fingerprint {
|
||||
aspect_ratio: "1.77".to_string(),
|
||||
device_pixel_ratio: "2.0".to_string(),
|
||||
hardware_concurrency: 8,
|
||||
};
|
||||
|
||||
let mut req = HeartbeatRequest {
|
||||
session_id: init_resp.session_id.clone(),
|
||||
prev_hash: init_resp.initial_hash.clone(),
|
||||
timestamp: now,
|
||||
entropy_data,
|
||||
stack_state,
|
||||
fingerprint,
|
||||
signature: "".to_string(),
|
||||
};
|
||||
|
||||
sign_request(&sk, &mut req);
|
||||
|
||||
// Verify successful heartbeat
|
||||
let next_salt = verify_heartbeat(&conn, &config, &req).unwrap();
|
||||
assert!(!next_salt.is_empty());
|
||||
|
||||
// Try duplicate/broken hash chain (prev_hash unchanged but expected next hash in DB)
|
||||
let res = verify_heartbeat(&conn, &config, &req);
|
||||
assert!(res.is_err());
|
||||
assert!(matches!(
|
||||
res.unwrap_err(),
|
||||
crate::errors::VerificationError::ChainBroken
|
||||
));
|
||||
}
|
||||
}
|
||||
+22
-11
@@ -10,17 +10,25 @@ pub struct StoreStats {
|
||||
pub max_chain_length: u64,
|
||||
}
|
||||
|
||||
pub fn init_db(path: &Path) -> Result<Connection, rusqlite::Error> {
|
||||
if path == Path::new(":memory:") {
|
||||
return init_schema(Connection::open_in_memory()?);
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
init_schema(Connection::open(path)?)
|
||||
pub type DbPool = r2d2::Pool<r2d2_sqlite::SqliteConnectionManager>;
|
||||
|
||||
pub fn init_pool(path: &Path) -> Result<DbPool, Box<dyn std::error::Error>> {
|
||||
let manager = if path == Path::new(":memory:") {
|
||||
r2d2_sqlite::SqliteConnectionManager::memory()
|
||||
} else {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
r2d2_sqlite::SqliteConnectionManager::file(path)
|
||||
};
|
||||
|
||||
let pool = r2d2::Pool::new(manager)?;
|
||||
let conn = pool.get()?;
|
||||
init_schema(&conn)?;
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
fn init_schema(conn: Connection) -> Result<Connection, rusqlite::Error> {
|
||||
fn init_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
@@ -33,7 +41,7 @@ fn init_schema(conn: Connection) -> Result<Connection, rusqlite::Error> {
|
||||
expires_at INTEGER NOT NULL
|
||||
);",
|
||||
)?;
|
||||
Ok(conn)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stats(conn: &Connection) -> Result<StoreStats, rusqlite::Error> {
|
||||
@@ -57,5 +65,8 @@ pub fn stats(conn: &Connection) -> Result<StoreStats, rusqlite::Error> {
|
||||
}
|
||||
|
||||
pub fn current_time_ms() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as u64
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64
|
||||
}
|
||||
+189
-7
@@ -1,7 +1,14 @@
|
||||
use crate::config::Config;
|
||||
use shared::protocol::EntropyData;
|
||||
|
||||
pub fn validate_mouse(data: &EntropyData) -> Result<(), Box<dyn std::error::Error>> {
|
||||
pub fn validate_mouse(
|
||||
data: &EntropyData,
|
||||
config: &Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let events = &data.events;
|
||||
if !config.require_mouse_activity && events.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if events.len() < 3 {
|
||||
return Err("few events".into());
|
||||
}
|
||||
@@ -19,18 +26,193 @@ pub fn validate_mouse(data: &EntropyData) -> Result<(), Box<dyn std::error::Erro
|
||||
pauses += 1;
|
||||
}
|
||||
}
|
||||
if total_dist < shared::constants::MIN_MOUSE_TOTAL_DIST {
|
||||
if total_dist < config.min_mouse_total_dist {
|
||||
return Err("insufficient distance".into());
|
||||
}
|
||||
// Speed in px/ms: total distance over elapsed wall-clock time of the event window.
|
||||
let total_time_ms =
|
||||
(events.last().unwrap().timestamp_ms - events[0].timestamp_ms).max(1.0);
|
||||
let total_time_ms = (events.last().unwrap().timestamp_ms - events[0].timestamp_ms).max(1.0);
|
||||
let avg_speed = total_dist / total_time_ms;
|
||||
if avg_speed > shared::constants::MAX_MOUSE_AVG_SPEED {
|
||||
if avg_speed > config.max_mouse_avg_speed {
|
||||
return Err("speed too high".into());
|
||||
}
|
||||
if pauses < shared::constants::MIN_PAUSE_COUNT {
|
||||
if pauses < config.min_pause_count {
|
||||
return Err("no pause".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use shared::protocol::MouseEvent;
|
||||
|
||||
fn get_default_config() -> Config {
|
||||
Config {
|
||||
min_mouse_total_dist: 10.0,
|
||||
max_mouse_avg_speed: 2.0,
|
||||
min_pause_count: 1,
|
||||
require_mouse_activity: true,
|
||||
..Config::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_mouse_success() {
|
||||
let config = get_default_config();
|
||||
// Mouse moves from (0,0) to (5,0) then (15,0) with a pause
|
||||
let events = vec![
|
||||
MouseEvent {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
timestamp_ms: 100.0,
|
||||
},
|
||||
MouseEvent {
|
||||
x: 5.0,
|
||||
y: 0.0,
|
||||
timestamp_ms: 200.0,
|
||||
},
|
||||
// Pause here (dist = 0, time diff = 100ms > 50ms)
|
||||
MouseEvent {
|
||||
x: 5.0,
|
||||
y: 0.0,
|
||||
timestamp_ms: 300.0,
|
||||
},
|
||||
MouseEvent {
|
||||
x: 15.0,
|
||||
y: 0.0,
|
||||
timestamp_ms: 400.0,
|
||||
},
|
||||
];
|
||||
let data = EntropyData { events };
|
||||
assert!(validate_mouse(&data, &config).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_mouse_insufficient_events() {
|
||||
let config = get_default_config();
|
||||
let events = vec![
|
||||
MouseEvent {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
timestamp_ms: 100.0,
|
||||
},
|
||||
MouseEvent {
|
||||
x: 5.0,
|
||||
y: 0.0,
|
||||
timestamp_ms: 200.0,
|
||||
},
|
||||
];
|
||||
let data = EntropyData { events };
|
||||
let res = validate_mouse(&data, &config);
|
||||
assert!(res.is_err());
|
||||
assert_eq!(res.unwrap_err().to_string(), "few events");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_mouse_insufficient_distance() {
|
||||
let config = get_default_config();
|
||||
// Total distance is only 5.0 < 10.0
|
||||
let events = vec![
|
||||
MouseEvent {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
timestamp_ms: 100.0,
|
||||
},
|
||||
MouseEvent {
|
||||
x: 2.0,
|
||||
y: 0.0,
|
||||
timestamp_ms: 200.0,
|
||||
},
|
||||
MouseEvent {
|
||||
x: 2.0,
|
||||
y: 0.0,
|
||||
timestamp_ms: 300.0,
|
||||
},
|
||||
MouseEvent {
|
||||
x: 5.0,
|
||||
y: 0.0,
|
||||
timestamp_ms: 400.0,
|
||||
},
|
||||
];
|
||||
let data = EntropyData { events };
|
||||
let res = validate_mouse(&data, &config);
|
||||
assert!(res.is_err());
|
||||
assert_eq!(res.unwrap_err().to_string(), "insufficient distance");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_mouse_too_fast() {
|
||||
let config = get_default_config();
|
||||
// Distance is 200.0, time difference is 70ms -> speed 2.85 > 2.0
|
||||
let events = vec![
|
||||
MouseEvent {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
timestamp_ms: 100.0,
|
||||
},
|
||||
MouseEvent {
|
||||
x: 100.0,
|
||||
y: 0.0,
|
||||
timestamp_ms: 105.0,
|
||||
},
|
||||
MouseEvent {
|
||||
x: 100.0,
|
||||
y: 0.0,
|
||||
timestamp_ms: 165.0,
|
||||
}, // pause
|
||||
MouseEvent {
|
||||
x: 200.0,
|
||||
y: 0.0,
|
||||
timestamp_ms: 170.0,
|
||||
},
|
||||
];
|
||||
let data = EntropyData { events };
|
||||
let res = validate_mouse(&data, &config);
|
||||
assert!(res.is_err());
|
||||
assert_eq!(res.unwrap_err().to_string(), "speed too high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_mouse_no_pauses() {
|
||||
let config = get_default_config();
|
||||
// Constant movement without any pause
|
||||
let events = vec![
|
||||
MouseEvent {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
timestamp_ms: 100.0,
|
||||
},
|
||||
MouseEvent {
|
||||
x: 5.0,
|
||||
y: 0.0,
|
||||
timestamp_ms: 200.0,
|
||||
},
|
||||
MouseEvent {
|
||||
x: 10.0,
|
||||
y: 0.0,
|
||||
timestamp_ms: 300.0,
|
||||
},
|
||||
MouseEvent {
|
||||
x: 15.0,
|
||||
y: 0.0,
|
||||
timestamp_ms: 400.0,
|
||||
},
|
||||
];
|
||||
let data = EntropyData { events };
|
||||
let res = validate_mouse(&data, &config);
|
||||
assert!(res.is_err());
|
||||
assert_eq!(res.unwrap_err().to_string(), "no pause");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_mouse_require_activity_toggle() {
|
||||
let mut config = get_default_config();
|
||||
config.require_mouse_activity = false;
|
||||
|
||||
let data = EntropyData { events: vec![] };
|
||||
assert!(validate_mouse(&data, &config).is_ok());
|
||||
|
||||
config.require_mouse_activity = true;
|
||||
assert!(validate_mouse(&data, &config).is_err());
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -43,4 +43,4 @@ pub fn generate_random_program(len_range: std::ops::RangeInclusive<usize>) -> Ve
|
||||
ops
|
||||
}
|
||||
|
||||
// Server does not need to execute the program; client does.
|
||||
// Server does not need to execute the program; client does.
|
||||
Reference in new issue
Block a user