sysknife_daemon/
checkpoint_sink.rs1use async_trait::async_trait;
25use sqlx_core::row::Row;
26use sqlx_postgres::{PgConnectOptions, PgPool, PgPoolOptions};
27use std::str::FromStr;
28use std::sync::Mutex;
29use std::time::Duration;
30
31use crate::audit_chain::Checkpoint;
32
33#[derive(Debug, thiserror::Error)]
34pub enum CheckpointSinkError {
35 #[error("checkpoint sink connection error: {0}")]
36 Connect(String),
37 #[error("checkpoint sink query error: {0}")]
38 Query(String),
39}
40
41impl From<sqlx_core::Error> for CheckpointSinkError {
42 fn from(e: sqlx_core::Error) -> Self {
43 CheckpointSinkError::Query(e.to_string())
44 }
45}
46
47#[async_trait]
51pub trait CheckpointSink: Send + Sync {
52 async fn append(&self, checkpoint: &Checkpoint) -> Result<(), CheckpointSinkError>;
54
55 async fn load_all(&self) -> Result<Vec<Checkpoint>, CheckpointSinkError>;
57}
58
59#[derive(Debug, Default)]
61pub struct InMemoryCheckpointSink {
62 stored: Mutex<Vec<Checkpoint>>,
63}
64
65impl InMemoryCheckpointSink {
66 pub fn new() -> Self {
67 Self::default()
68 }
69}
70
71#[async_trait]
72impl CheckpointSink for InMemoryCheckpointSink {
73 async fn append(&self, checkpoint: &Checkpoint) -> Result<(), CheckpointSinkError> {
74 let mut stored = self
75 .stored
76 .lock()
77 .map_err(|e| CheckpointSinkError::Query(format!("lock poisoned: {e}")))?;
78 stored.push(checkpoint.clone());
79 Ok(())
80 }
81
82 async fn load_all(&self) -> Result<Vec<Checkpoint>, CheckpointSinkError> {
83 let stored = self
84 .stored
85 .lock()
86 .map_err(|e| CheckpointSinkError::Query(format!("lock poisoned: {e}")))?;
87 let mut out = stored.clone();
88 out.sort_by_key(|c| c.seq);
89 Ok(out)
90 }
91}
92
93#[derive(Debug)]
97pub struct PostgresCheckpointSink {
98 pool: PgPool,
99}
100
101impl PostgresCheckpointSink {
102 pub async fn connect(url: &str) -> Result<Self, CheckpointSinkError> {
105 let opts = PgConnectOptions::from_str(url).map_err(|_| {
107 CheckpointSinkError::Connect("invalid checkpoint database URL".to_string())
108 })?;
109 let pool = PgPoolOptions::new()
110 .max_connections(4)
111 .acquire_timeout(Duration::from_secs(10))
112 .connect_with(opts)
113 .await
114 .map_err(|e| CheckpointSinkError::Connect(e.to_string()))?;
115 let sink = Self { pool };
116 sink.initialize().await?;
117 Ok(sink)
118 }
119
120 async fn initialize(&self) -> Result<(), CheckpointSinkError> {
121 let row = sqlx_core::query::query(
126 "SELECT to_regclass('audit_checkpoints') IS NOT NULL AS present",
127 )
128 .fetch_one(&self.pool)
129 .await?;
130 let present: bool = row.try_get("present")?;
131 if present {
132 return Ok(());
133 }
134 sqlx_core::query::query(
135 "CREATE TABLE IF NOT EXISTS audit_checkpoints (\
136 seq BIGINT NOT NULL, \
137 chain_tip TEXT NOT NULL, \
138 created_at TEXT NOT NULL, \
139 signature TEXT NOT NULL\
140 )",
141 )
142 .execute(&self.pool)
143 .await?;
144 Ok(())
145 }
146}
147
148#[async_trait]
149impl CheckpointSink for PostgresCheckpointSink {
150 async fn append(&self, checkpoint: &Checkpoint) -> Result<(), CheckpointSinkError> {
151 sqlx_core::query::query(
152 "INSERT INTO audit_checkpoints (seq, chain_tip, created_at, signature) \
153 VALUES ($1, $2, $3, $4)",
154 )
155 .bind(i64::try_from(checkpoint.seq).map_err(|_| {
156 CheckpointSinkError::Query("checkpoint seq exceeds i64 range".to_string())
157 })?)
158 .bind(&checkpoint.chain_tip)
159 .bind(&checkpoint.created_at)
160 .bind(&checkpoint.signature)
161 .execute(&self.pool)
162 .await?;
163 Ok(())
164 }
165
166 async fn load_all(&self) -> Result<Vec<Checkpoint>, CheckpointSinkError> {
167 let rows = sqlx_core::query::query(
168 "SELECT seq, chain_tip, created_at, signature \
169 FROM audit_checkpoints ORDER BY seq ASC",
170 )
171 .fetch_all(&self.pool)
172 .await?;
173
174 let mut out = Vec::with_capacity(rows.len());
175 for row in rows {
176 let seq: i64 = row.try_get("seq")?;
177 out.push(Checkpoint {
178 seq: u64::try_from(seq).map_err(|e| {
179 CheckpointSinkError::Query(format!("negative checkpoint seq: {e}"))
180 })?,
181 chain_tip: row.try_get("chain_tip")?,
182 created_at: row.try_get("created_at")?,
183 signature: row.try_get("signature")?,
184 });
185 }
186 Ok(out)
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use crate::audit_chain::{
194 verify_checkpoints, AuditKey, ChainContent, ChainRow, CheckpointOutcome,
195 };
196 use sysknife_types::RiskLevel;
197
198 fn key() -> AuditKey {
199 AuditKey::from_bytes(vec![0x42; 32])
200 }
201
202 fn build_chain(key: &AuditKey, count: usize) -> Vec<ChainRow> {
204 let mut rows = Vec::with_capacity(count);
205 let mut prev = String::new();
206 for i in 0..count {
207 let seq = (i + 1) as u64;
208 let txid = format!("tx{i}");
209 let content = ChainContent {
210 seq,
211 key_id: "v1",
212 transaction_id: &txid,
213 request_id: "req",
214 request_hash: "hash",
215 action_name: "UpdateSystem",
216 risk_level: RiskLevel::High,
217 summary: "s",
218 approval_id: None,
219 warnings_json: "[]",
220 created_at: "2026-04-24T12:00:00Z",
221 };
222 let hash = key.chain_hash(&content, &prev);
223 rows.push(ChainRow {
224 seq,
225 key_id: "v1".to_string(),
226 transaction_id: txid,
227 request_id: "req".to_string(),
228 request_hash: "hash".to_string(),
229 action_name: "UpdateSystem".to_string(),
230 risk_level: RiskLevel::High,
231 summary: "s".to_string(),
232 approval_id: None,
233 warnings_json: "[]".to_string(),
234 created_at: "2026-04-24T12:00:00Z".to_string(),
235 prev_chain_hash: prev.clone(),
236 chain_hash: hash.clone(),
237 });
238 prev = hash;
239 }
240 rows
241 }
242
243 #[tokio::test]
244 async fn in_memory_append_and_load_round_trip() {
245 let key = key();
246 let rows = build_chain(&key, 3);
247 let sink = InMemoryCheckpointSink::new();
248 let cp = key.sign_checkpoint(3, &rows[2].chain_hash, "2026-04-24T12:00:00Z");
249 sink.append(&cp).await.unwrap();
250 let loaded = sink.load_all().await.unwrap();
251 assert_eq!(loaded, vec![cp]);
252 }
253
254 #[tokio::test]
255 async fn anchored_checkpoints_verify_and_catch_truncation() {
256 let key = key();
257 let full = build_chain(&key, 5);
258 let sink = InMemoryCheckpointSink::new();
259 let cp = key.sign_checkpoint(5, &full[4].chain_hash, "2026-04-24T12:00:00Z");
261 sink.append(&cp).await.unwrap();
262
263 let anchored = sink.load_all().await.unwrap();
265 assert_eq!(
266 verify_checkpoints(&key.verifying_key_hex(), &full, &anchored),
267 CheckpointOutcome::Consistent {
268 checkpoints_checked: 1
269 }
270 );
271
272 let truncated = &full[..3];
275 assert!(matches!(
276 verify_checkpoints(&key.verifying_key_hex(), truncated, &anchored),
277 CheckpointOutcome::Truncated {
278 checkpoint_seq: 5,
279 current_max_seq: 3
280 }
281 ));
282 }
283}