#![allow(dead_code)]
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TracePayload {
pub session_id: Option<String>,
pub vendor: String,
pub steps: Vec<TraceStep>,
pub recorded_at_unix: i64,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub struct TraceStep {
pub dx: f32,
pub dy: f32,
pub dt_ms: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationError {
TooFewSteps,
TooManySteps,
DurationTooLong,
SubMillisecondCadence,
EmptyVendor,
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ValidationError::TooFewSteps => write!(f, "trace has fewer than 3 steps"),
ValidationError::TooManySteps => write!(f, "trace has more than 500 steps"),
ValidationError::DurationTooLong => {
write!(f, "trace total duration exceeds 60_000 ms")
}
ValidationError::SubMillisecondCadence => {
write!(
f,
"trace has steps with dt_ms == 0 (sub-ms cadence is synthetic)"
)
}
ValidationError::EmptyVendor => write!(f, "vendor field must be non-empty"),
}
}
}
impl std::error::Error for ValidationError {}
pub fn validate(payload: &TracePayload) -> Result<(), ValidationError> {
if payload.vendor.trim().is_empty() {
return Err(ValidationError::EmptyVendor);
}
if payload.steps.len() < 3 {
return Err(ValidationError::TooFewSteps);
}
if payload.steps.len() > 500 {
return Err(ValidationError::TooManySteps);
}
if payload.steps.iter().any(|s| s.dt_ms == 0) {
return Err(ValidationError::SubMillisecondCadence);
}
let total_ms: u64 = payload.steps.iter().map(|s| s.dt_ms as u64).sum();
if total_ms > 60_000 {
return Err(ValidationError::DurationTooLong);
}
Ok(())
}
pub struct TraceStore {
root: PathBuf,
write_lock: Mutex<()>,
}
impl TraceStore {
pub fn open(root: impl AsRef<Path>) -> Result<Self> {
let root = root.as_ref().to_path_buf();
std::fs::create_dir_all(&root)
.with_context(|| format!("creating trace store dir {}", root.display()))?;
Ok(Self {
root,
write_lock: Mutex::new(()),
})
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn append(&self, payload: &TracePayload) -> Result<()> {
let _guard = self
.write_lock
.lock()
.map_err(|e| anyhow::anyhow!("trace store write lock poisoned: {e}"))?;
let path = self.path_for(payload);
let mut line = serde_json::to_string(payload).context("serialising trace payload")?;
line.push('\n');
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("opening trace file {}", path.display()))?;
file.write_all(line.as_bytes())
.context("writing trace line")?;
Ok(())
}
pub fn load_vendor_day(&self, vendor: &str, unix_seconds: i64) -> Result<Vec<TracePayload>> {
let path = self.root.join(format!(
"{}-{}.jsonl",
sanitise(vendor),
unix_to_iso_date(unix_seconds)
));
if !path.exists() {
return Ok(Vec::new());
}
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
let mut out = Vec::new();
for (i, line) in raw.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let p: TracePayload = serde_json::from_str(line).with_context(|| {
format!("parsing trace on line {} of {}", i + 1, path.display())
})?;
out.push(p);
}
Ok(out)
}
fn path_for(&self, payload: &TracePayload) -> PathBuf {
self.root.join(format!(
"{}-{}.jsonl",
sanitise(&payload.vendor),
unix_to_iso_date(payload.recorded_at_unix)
))
}
}
fn sanitise(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_alphanumeric() || matches!(c, '-' | '_') {
c
} else {
'_'
}
})
.collect()
}
fn unix_to_iso_date(unix_seconds: i64) -> String {
let days = unix_seconds.div_euclid(86_400);
let (y, m, d) = days_to_ymd(days);
format!("{y:04}-{m:02}-{d:02}")
}
fn days_to_ymd(days: i64) -> (i32, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 {
z.div_euclid(146_097)
} else {
(z - 146_096).div_euclid(146_097)
};
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = (yoe as i64 + era * 400) as i32;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn good_payload() -> TracePayload {
TracePayload {
session_id: None,
vendor: "recaptcha".into(),
steps: vec![
TraceStep {
dx: 1.0,
dy: 1.0,
dt_ms: 10,
},
TraceStep {
dx: 2.0,
dy: 1.5,
dt_ms: 12,
},
TraceStep {
dx: 1.5,
dy: 2.0,
dt_ms: 11,
},
TraceStep {
dx: 1.0,
dy: 1.0,
dt_ms: 13,
},
],
recorded_at_unix: 1_700_000_000,
}
}
#[test]
fn validate_accepts_realistic_payload() {
assert!(validate(&good_payload()).is_ok());
}
#[test]
fn validate_rejects_too_few_steps() {
let mut p = good_payload();
p.steps.truncate(2);
assert_eq!(validate(&p), Err(ValidationError::TooFewSteps));
}
#[test]
fn validate_rejects_too_many_steps() {
let mut p = good_payload();
p.steps = (0..1000)
.map(|_| TraceStep {
dx: 1.0,
dy: 1.0,
dt_ms: 10,
})
.collect();
assert_eq!(validate(&p), Err(ValidationError::TooManySteps));
}
#[test]
fn validate_rejects_zero_dt_synthetic_steps() {
let mut p = good_payload();
p.steps[1].dt_ms = 0;
assert_eq!(validate(&p), Err(ValidationError::SubMillisecondCadence));
}
#[test]
fn validate_rejects_overlong_total_duration() {
let mut p = good_payload();
p.steps = (0..100)
.map(|_| TraceStep {
dx: 1.0,
dy: 1.0,
dt_ms: 1_000,
})
.collect();
assert_eq!(validate(&p), Err(ValidationError::DurationTooLong));
}
#[test]
fn validate_rejects_empty_vendor() {
let mut p = good_payload();
p.vendor = " ".into();
assert_eq!(validate(&p), Err(ValidationError::EmptyVendor));
}
#[test]
fn store_round_trips_payload() {
let tmp = tempdir().unwrap();
let store = TraceStore::open(tmp.path()).unwrap();
let p = good_payload();
store.append(&p).unwrap();
let back = store
.load_vendor_day(&p.vendor, p.recorded_at_unix)
.unwrap();
assert_eq!(back.len(), 1);
assert_eq!(back[0], p);
}
#[test]
fn store_appends_multiple_payloads_to_same_day_file() {
let tmp = tempdir().unwrap();
let store = TraceStore::open(tmp.path()).unwrap();
for _ in 0..5 {
store.append(&good_payload()).unwrap();
}
let back = store.load_vendor_day("recaptcha", 1_700_000_000).unwrap();
assert_eq!(back.len(), 5);
}
#[test]
fn store_partitions_by_vendor() {
let tmp = tempdir().unwrap();
let store = TraceStore::open(tmp.path()).unwrap();
let mut p1 = good_payload();
p1.vendor = "recaptcha".into();
let mut p2 = good_payload();
p2.vendor = "hcaptcha".into();
store.append(&p1).unwrap();
store.append(&p2).unwrap();
assert_eq!(
store
.load_vendor_day("recaptcha", p1.recorded_at_unix)
.unwrap()
.len(),
1
);
assert_eq!(
store
.load_vendor_day("hcaptcha", p2.recorded_at_unix)
.unwrap()
.len(),
1
);
}
#[test]
fn unix_to_iso_date_handles_known_dates() {
assert_eq!(unix_to_iso_date(1_700_000_000), "2023-11-14");
assert_eq!(unix_to_iso_date(0), "1970-01-01");
assert_eq!(unix_to_iso_date(946_684_800), "2000-01-01");
}
#[test]
fn unix_to_iso_date_handles_pre_epoch_negative_seconds() {
assert_eq!(unix_to_iso_date(-1), "1969-12-31");
}
#[test]
fn validation_error_implements_display_and_error() {
let e: Box<dyn std::error::Error> = Box::new(ValidationError::TooFewSteps);
assert!(e.to_string().contains("3 steps"));
}
#[test]
fn store_load_vendor_day_returns_empty_for_unknown_combo() {
let tmp = tempdir().unwrap();
let store = TraceStore::open(tmp.path()).unwrap();
let back = store.load_vendor_day("never-seen", 0).unwrap();
assert!(back.is_empty());
}
}