1use rusqlite::Connection;
7use std::path::Path;
8use thiserror::Error;
9use time::format_description::well_known::Rfc3339;
10use time::OffsetDateTime;
11use uuid::Uuid;
12
13use crate::envelope::CheckResult;
14
15pub const SCHEMA_VERSION: u32 = 2;
16
17pub const FAILOPEN_RULE: &str = "pushkin.failopen.malformed_input";
20
21pub const ESCALATION_RULE: &str = "pushkin.escalation";
24
25const MIGRATIONS: &[&str] = &[
29 "
30 CREATE TABLE IF NOT EXISTS schema_meta (
31 version INTEGER NOT NULL
32 );
33 CREATE TABLE IF NOT EXISTS events (
34 id INTEGER PRIMARY KEY AUTOINCREMENT,
35 session TEXT NOT NULL,
36 seq INTEGER NOT NULL,
37 ts TEXT NOT NULL, -- UTC ISO-8601 (RFC 3339)
38 decision TEXT NOT NULL,
39 rule TEXT,
40 file TEXT,
41 payload TEXT NOT NULL,
42 UNIQUE (session, seq)
43 );
44 CREATE TRIGGER IF NOT EXISTS events_no_update
45 BEFORE UPDATE ON events
46 BEGIN SELECT RAISE(ABORT, 'events are append-only'); END;
47 CREATE TRIGGER IF NOT EXISTS events_no_delete
48 BEFORE DELETE ON events
49 BEGIN SELECT RAISE(ABORT, 'events are append-only'); END;
50 ",
51 "
55 CREATE TABLE IF NOT EXISTS delivered_slices (
56 session TEXT NOT NULL,
57 cwd TEXT NOT NULL,
58 slice_key TEXT NOT NULL,
59 delivered_at_emission INTEGER NOT NULL,
60 PRIMARY KEY (session, cwd, slice_key)
61 );
62 CREATE TABLE IF NOT EXISTS delivery_counters (
63 session TEXT NOT NULL,
64 cwd TEXT NOT NULL,
65 emissions INTEGER NOT NULL,
66 PRIMARY KEY (session, cwd)
67 );
68 ",
69];
70
71#[derive(Debug, Error)]
72pub enum EventLogError {
73 #[error("event log storage error: {0}")]
74 Storage(#[from] rusqlite::Error),
75 #[error("event serialization error: {0}")]
76 Serialize(#[from] serde_json::Error),
77 #[error("timestamp formatting error: {0}")]
78 Timestamp(#[from] time::error::Format),
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct SessionId(String);
84
85impl SessionId {
86 #[must_use]
89 pub fn from_name(name: &str) -> Self {
90 Self(name.to_owned())
91 }
92
93 #[must_use]
94 pub fn as_str(&self) -> &str {
95 &self.0
96 }
97}
98
99#[derive(Debug)]
101pub struct StatsSummary {
102 pub blocks_by_rule: Vec<(String, u64)>,
103 pub compression_events: u64,
104 pub compression_saved_chars: u64,
105 pub nudge_arms: Vec<(String, u64)>,
106 pub failopen_events: u64,
107}
108
109#[derive(Debug)]
111pub struct Telemetry<'a> {
112 pub rule: &'a str,
113 pub payload: String,
114}
115
116#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
118#[serde(deny_unknown_fields)]
119pub struct GateEvent {
120 pub session: String,
121 pub seq: u64,
122 pub ts: String,
123}
124
125pub struct EventLog {
126 conn: Connection,
127}
128
129impl EventLog {
130 pub fn open(path: impl AsRef<Path>) -> Result<Self, EventLogError> {
136 let conn = Connection::open(path)?;
137 apply_migrations(&conn)?;
138 Ok(Self { conn })
139 }
140
141 pub fn begin_session(&self) -> Result<SessionId, EventLogError> {
146 Ok(SessionId(Uuid::new_v4().to_string()))
147 }
148
149 pub fn append(
154 &self,
155 session: &SessionId,
156 result: &CheckResult,
157 ) -> Result<GateEvent, EventLogError> {
158 let rule = result.violations.first().map(|v| v.rule.clone());
159 let file = result.violations.first().map(|v| v.file.clone());
160 self.append_row(
161 session,
162 result.decision.as_str(),
163 rule,
164 file,
165 serde_json::to_string(result)?,
166 )
167 }
168
169 pub fn append_failopen(
174 &self,
175 session: &SessionId,
176 detail: &str,
177 ) -> Result<GateEvent, EventLogError> {
178 let payload = serde_json::json!({ "failopen": true, "detail": detail }).to_string();
179 self.append_row(
180 session,
181 "allow",
182 Some(FAILOPEN_RULE.to_owned()),
183 None,
184 payload,
185 )
186 }
187
188 pub fn append_escalation(
193 &self,
194 session: &SessionId,
195 detail: &str,
196 ) -> Result<GateEvent, EventLogError> {
197 let payload = serde_json::json!({ "escalation": true, "detail": detail }).to_string();
198 self.append_row(
199 session,
200 "block",
201 Some(ESCALATION_RULE.to_owned()),
202 None,
203 payload,
204 )
205 }
206
207 pub fn append_telemetry(
214 &self,
215 session: &SessionId,
216 telemetry: Telemetry<'_>,
217 ) -> Result<GateEvent, EventLogError> {
218 self.append_row(
219 session,
220 "telemetry",
221 Some(telemetry.rule.to_owned()),
222 None,
223 telemetry.payload,
224 )
225 }
226
227 pub fn attempts(
233 &self,
234 session: &SessionId,
235 rule: &str,
236 file: &str,
237 ) -> Result<u64, EventLogError> {
238 let count: u64 = self.conn.query_row(
239 "SELECT COUNT(*) FROM events
240 WHERE session = ?1 AND rule IN (?2, ?3) AND file = ?4",
241 (
242 session.as_str(),
243 rule,
244 crate::legacy::legacy_rule_id(rule).as_ref(),
245 file,
246 ),
247 |row| row.get(0),
248 )?;
249 Ok(count)
250 }
251
252 pub fn denied_since(&self, rule: &str, file: &str, since: &str) -> Result<bool, EventLogError> {
301 let count: u64 = self.conn.query_row(
302 "SELECT COUNT(*) FROM events
303 WHERE decision = 'block' AND rule IN (?1, ?2)
304 AND file = ?3 AND substr(ts, 1, 19) >= substr(?4, 1, 19)",
305 (
306 rule,
307 crate::legacy::legacy_rule_id(rule).as_ref(),
308 file,
309 since,
310 ),
311 |row| row.get(0),
312 )?;
313 Ok(count > 0)
314 }
315
316 pub fn block_count(&self, session: &SessionId) -> Result<u64, EventLogError> {
321 let count: u64 = self.conn.query_row(
322 "SELECT COUNT(*) FROM events WHERE session = ?1 AND decision = 'block'",
323 [session.as_str()],
324 |row| row.get(0),
325 )?;
326 Ok(count)
327 }
328
329 pub fn rule_count(&self, session: &SessionId, rule: &str) -> Result<u64, EventLogError> {
334 let count: u64 = self.conn.query_row(
335 "SELECT COUNT(*) FROM events WHERE session = ?1 AND rule IN (?2, ?3)",
336 (
337 session.as_str(),
338 rule,
339 crate::legacy::legacy_rule_id(rule).as_ref(),
340 ),
341 |row| row.get(0),
342 )?;
343 Ok(count)
344 }
345
346 pub fn failopen_count(&self, session: &SessionId) -> Result<u64, EventLogError> {
351 let count: u64 = self.conn.query_row(
352 "SELECT COUNT(*) FROM events WHERE session = ?1 AND rule IN (?2, ?3)",
353 (
354 session.as_str(),
355 FAILOPEN_RULE,
356 crate::legacy::legacy_rule_id(FAILOPEN_RULE).as_ref(),
357 ),
358 |row| row.get(0),
359 )?;
360 Ok(count)
361 }
362
363 pub fn stats(&self) -> Result<StatsSummary, EventLogError> {
372 let mut blocks = self.conn.prepare(
373 "SELECT rule, COUNT(*) FROM events
374 WHERE decision = 'block' AND rule IS NOT NULL
375 GROUP BY rule ORDER BY COUNT(*) DESC, rule",
376 )?;
377 let raw_blocks = blocks
378 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
379 .collect::<Result<Vec<(String, u64)>, _>>()?;
380 let blocks_by_rule = group_modern(raw_blocks);
381 let (compression_events, compression_saved_chars): (u64, u64) = self.conn.query_row(
382 "SELECT COUNT(*), COALESCE(SUM(json_extract(payload, '$.saved_chars')), 0)
383 FROM events WHERE rule IN (?1, ?2)",
384 (
385 "pushkin.compression",
386 crate::legacy::legacy_rule_id("pushkin.compression").as_ref(),
387 ),
388 |row| Ok((row.get(0)?, row.get(1)?)),
389 )?;
390 let mut arms = self.conn.prepare(
391 "SELECT COALESCE(json_extract(payload, '$.arm'), 'unknown'), COUNT(*)
392 FROM events WHERE rule IN (?1, ?2) GROUP BY 1 ORDER BY 1",
393 )?;
394 let nudge_arms = arms
395 .query_map(
396 (
397 "pushkin.nudge",
398 crate::legacy::legacy_rule_id("pushkin.nudge").as_ref(),
399 ),
400 |row| Ok((row.get(0)?, row.get(1)?)),
401 )?
402 .collect::<Result<Vec<(String, u64)>, _>>()?;
403 let failopen_events: u64 = self.conn.query_row(
404 "SELECT COUNT(*) FROM events WHERE rule IN (?1, ?2)",
405 (
406 FAILOPEN_RULE,
407 crate::legacy::legacy_rule_id(FAILOPEN_RULE).as_ref(),
408 ),
409 |row| row.get(0),
410 )?;
411 Ok(StatsSummary {
412 blocks_by_rule,
413 compression_events,
414 compression_saved_chars,
415 nudge_arms,
416 failopen_events,
417 })
418 }
419
420 pub fn decision_counts(&self) -> Result<(u64, u64), EventLogError> {
427 let row = self.conn.query_row(
428 "SELECT
429 COUNT(*) FILTER (WHERE decision IN ('allow', 'block')),
430 COUNT(*) FILTER (WHERE decision = 'block')
431 FROM events WHERE rule IS NULL OR rule NOT IN (?1, ?2)",
432 (
433 ESCALATION_RULE,
434 crate::legacy::legacy_rule_id(ESCALATION_RULE).as_ref(),
435 ),
436 |row| Ok((row.get(0)?, row.get(1)?)),
437 )?;
438 Ok(row)
439 }
440
441 pub fn latest_session_escalated(&self) -> Result<bool, EventLogError> {
446 let escalated: u64 = self.conn.query_row(
447 "SELECT COUNT(*) FROM events
448 WHERE rule IN (?1, ?2) AND session =
449 (SELECT session FROM events ORDER BY id DESC LIMIT 1)",
450 (
451 ESCALATION_RULE,
452 crate::legacy::legacy_rule_id(ESCALATION_RULE).as_ref(),
453 ),
454 |row| row.get(0),
455 )?;
456 Ok(escalated > 0)
457 }
458
459 pub fn mean_attempts_to_compliance(&self) -> Result<Option<f64>, EventLogError> {
467 let mut statement = self.conn.prepare(
468 "SELECT session, decision FROM events
469 WHERE decision IN ('allow', 'block')
470 AND (rule IS NULL OR rule NOT IN (?1, ?2))
471 ORDER BY session, seq",
472 )?;
473 let rows = statement
474 .query_map(
475 (
476 ESCALATION_RULE,
477 crate::legacy::legacy_rule_id(ESCALATION_RULE).as_ref(),
478 ),
479 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
480 )?
481 .collect::<Result<Vec<_>, _>>()?;
482
483 let mut recoveries: Vec<u64> = Vec::new();
484 let mut current_session = String::new();
485 let mut open_blocks: u64 = 0;
486 for (session, decision) in rows {
487 if session != current_session {
488 current_session = session;
489 open_blocks = 0;
490 }
491 match decision.as_str() {
492 "block" => open_blocks += 1,
493 "allow" if open_blocks > 0 => {
494 recoveries.push(open_blocks + 1);
495 open_blocks = 0;
496 }
497 _ => {}
498 }
499 }
500 if recoveries.is_empty() {
501 return Ok(None);
502 }
503 #[allow(clippy::cast_precision_loss)]
504 let mean = recoveries.iter().sum::<u64>() as f64 / recoveries.len() as f64;
505 Ok(Some(mean))
506 }
507
508 pub fn schema_version(&self) -> Result<u32, EventLogError> {
513 let version: u32 = self
514 .conn
515 .query_row("SELECT version FROM schema_meta", [], |row| row.get(0))?;
516 Ok(version)
517 }
518
519 fn append_row(
520 &self,
521 session: &SessionId,
522 decision: &str,
523 rule: Option<String>,
524 file: Option<String>,
525 payload: String,
526 ) -> Result<GateEvent, EventLogError> {
527 let next_seq: u64 = self.conn.query_row(
528 "SELECT COALESCE(MAX(seq), 0) + 1 FROM events WHERE session = ?1",
529 [session.as_str()],
530 |row| row.get(0),
531 )?;
532 let ts = OffsetDateTime::now_utc().format(&Rfc3339)?;
533 self.conn.execute(
534 "INSERT INTO events (session, seq, ts, decision, rule, file, payload)
535 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
536 (
537 session.as_str(),
538 next_seq,
539 &ts,
540 decision,
541 rule,
542 file,
543 payload,
544 ),
545 )?;
546 Ok(GateEvent {
547 session: session.as_str().to_owned(),
548 seq: next_seq,
549 ts,
550 })
551 }
552}
553
554fn group_modern(rows: Vec<(String, u64)>) -> Vec<(String, u64)> {
558 let mut merged: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
559 for (rule, count) in rows {
560 *merged
561 .entry(crate::legacy::modern_rule_id(&rule).into_owned())
562 .or_insert(0) += count;
563 }
564 let mut grouped: Vec<(String, u64)> = merged.into_iter().collect();
565 grouped.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
566 grouped
567}
568
569pub(crate) fn apply_migrations(conn: &Connection) -> Result<(), EventLogError> {
570 let current: u32 = conn
571 .query_row("SELECT version FROM schema_meta", [], |row| row.get(0))
572 .unwrap_or(0);
573 for (index, step) in MIGRATIONS.iter().enumerate() {
574 let step_version = u32::try_from(index).unwrap_or(u32::MAX).saturating_add(1);
575 if step_version > current {
576 conn.execute_batch(step)?;
577 }
578 }
579 if current == 0 {
580 conn.execute(
581 "INSERT INTO schema_meta (version) VALUES (?1)",
582 [SCHEMA_VERSION],
583 )?;
584 } else if current < SCHEMA_VERSION {
585 conn.execute("UPDATE schema_meta SET version = ?1", [SCHEMA_VERSION])?;
588 }
589 Ok(())
590}