1use crate::{SqliteSyncError, SqliteSyncResult, SqliteSyncStore};
12use rusqlite::{params, OptionalExtension, TransactionBehavior};
13
14const MAX_NAMESPACE_BYTES: usize = 128;
15const MAX_OPAQUE_KEY_BYTES: usize = 512;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct SqliteSyncTombstone {
20 pub namespace: String,
22 pub opaque_key: String,
24 pub deleted_sequence: u64,
26 pub payload_hash: String,
28 pub expires_at_ms: u64,
30}
31
32#[derive(Debug, Clone)]
34pub struct SqliteSyncTombstoneStore {
35 store: SqliteSyncStore,
36}
37
38impl SqliteSyncTombstoneStore {
39 pub(crate) fn new(store: SqliteSyncStore) -> Self {
40 Self { store }
41 }
42
43 pub fn record(&self, tombstone: &SqliteSyncTombstone) -> SqliteSyncResult<bool> {
45 validate_tombstone(tombstone)?;
46 self.store.with_connection(|connection| {
47 let transaction = connection
48 .transaction_with_behavior(TransactionBehavior::Immediate)
49 .map_err(SqliteSyncError::database)?;
50 let existing: Option<(i64, String, i64)> = transaction
51 .query_row(
52 "SELECT deleted_sequence, payload_hash, expires_at_ms
53 FROM appcore_sync_tombstone WHERE namespace = ?1 AND opaque_key = ?2",
54 params![tombstone.namespace, tombstone.opaque_key],
55 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
56 )
57 .optional()
58 .map_err(SqliteSyncError::database)?;
59 let incoming_sequence = to_i64(tombstone.deleted_sequence, "tombstone sequence")?;
60 let incoming_expiry = to_i64(tombstone.expires_at_ms, "tombstone expiry")?;
61 if let Some((sequence, hash, expiry)) = existing {
62 if incoming_sequence < sequence {
63 return Ok(false);
64 }
65 if incoming_sequence == sequence {
66 return if hash == tombstone.payload_hash && expiry == incoming_expiry {
67 Ok(false)
68 } else {
69 Err(SqliteSyncError::CorruptRecord("tombstone conflict"))
70 };
71 }
72 } else if count_tombstones(&transaction)? >= self.store.config().max_tombstones {
73 return Err(SqliteSyncError::CapacityExceeded("tombstone"));
74 }
75 let changed = transaction
76 .execute(
77 "INSERT INTO appcore_sync_tombstone
78 (namespace, opaque_key, deleted_sequence, payload_hash, expires_at_ms)
79 VALUES (?1, ?2, ?3, ?4, ?5)
80 ON CONFLICT(namespace, opaque_key) DO UPDATE SET
81 deleted_sequence = excluded.deleted_sequence,
82 payload_hash = excluded.payload_hash,
83 expires_at_ms = excluded.expires_at_ms
84 WHERE excluded.deleted_sequence >= appcore_sync_tombstone.deleted_sequence",
85 params![
86 tombstone.namespace,
87 tombstone.opaque_key,
88 incoming_sequence,
89 tombstone.payload_hash,
90 incoming_expiry
91 ],
92 )
93 .map_err(SqliteSyncError::database)?;
94 transaction.commit().map_err(SqliteSyncError::database)?;
95 Ok(changed == 1)
96 })
97 }
98
99 pub fn active(&self, now_ms: u64, limit: usize) -> SqliteSyncResult<Vec<SqliteSyncTombstone>> {
101 if limit == 0 || limit > self.store.config().max_tombstones {
102 return Err(SqliteSyncError::CapacityExceeded("tombstone read"));
103 }
104 self.store.with_connection(|connection| {
105 let mut statement = connection
106 .prepare(
107 "SELECT namespace, opaque_key, deleted_sequence, payload_hash, expires_at_ms
108 FROM appcore_sync_tombstone WHERE expires_at_ms > ?1
109 ORDER BY namespace, opaque_key LIMIT ?2",
110 )
111 .map_err(SqliteSyncError::database)?;
112 let rows = statement
113 .query_map(
114 params![
115 to_i64(now_ms, "current time")?,
116 to_i64(limit as u64, "limit")?
117 ],
118 |row| {
119 Ok((
120 row.get::<_, String>(0)?,
121 row.get::<_, String>(1)?,
122 row.get::<_, i64>(2)?,
123 row.get::<_, String>(3)?,
124 row.get::<_, i64>(4)?,
125 ))
126 },
127 )
128 .map_err(SqliteSyncError::database)?;
129 let mut tombstones = Vec::new();
130 for row in rows {
131 let (namespace, opaque_key, sequence, payload_hash, expiry) =
132 row.map_err(SqliteSyncError::database)?;
133 tombstones.push(SqliteSyncTombstone {
134 namespace,
135 opaque_key,
136 deleted_sequence: to_u64(sequence, "tombstone sequence")?,
137 payload_hash,
138 expires_at_ms: to_u64(expiry, "tombstone expiry")?,
139 });
140 }
141 Ok(tombstones)
142 })
143 }
144
145 pub fn prune_expired(&self, now_ms: u64, limit: usize) -> SqliteSyncResult<usize> {
147 if limit == 0 || limit > self.store.config().max_tombstones {
148 return Err(SqliteSyncError::CapacityExceeded("tombstone prune"));
149 }
150 self.store.with_connection(|connection| {
151 connection
152 .execute(
153 "DELETE FROM appcore_sync_tombstone WHERE (namespace, opaque_key) IN (
154 SELECT namespace, opaque_key FROM appcore_sync_tombstone
155 WHERE expires_at_ms <= ?1 ORDER BY expires_at_ms LIMIT ?2
156 )",
157 params![
158 to_i64(now_ms, "current time")?,
159 to_i64(limit as u64, "limit")?
160 ],
161 )
162 .map_err(SqliteSyncError::database)
163 })
164 }
165
166 pub fn len(&self) -> SqliteSyncResult<usize> {
168 self.store
169 .with_connection(|connection| count_tombstones(connection))
170 }
171
172 pub fn is_empty(&self) -> SqliteSyncResult<bool> {
174 self.len().map(|length| length == 0)
175 }
176}
177
178fn count_tombstones(connection: &rusqlite::Connection) -> SqliteSyncResult<usize> {
179 let count: i64 = connection
180 .query_row("SELECT COUNT(*) FROM appcore_sync_tombstone", [], |row| {
181 row.get(0)
182 })
183 .map_err(SqliteSyncError::database)?;
184 usize::try_from(count).map_err(|_| SqliteSyncError::CorruptRecord("tombstone count"))
185}
186
187pub(crate) fn validate_tombstone(tombstone: &SqliteSyncTombstone) -> SqliteSyncResult<()> {
188 if !valid_identifier(&tombstone.namespace, MAX_NAMESPACE_BYTES)
189 || !valid_identifier(&tombstone.opaque_key, MAX_OPAQUE_KEY_BYTES)
190 || tombstone.deleted_sequence == 0
191 || tombstone.expires_at_ms == 0
192 || tombstone.payload_hash.len() != 64
193 || !tombstone
194 .payload_hash
195 .bytes()
196 .all(|byte| byte.is_ascii_hexdigit())
197 {
198 return Err(SqliteSyncError::CorruptRecord("tombstone input"));
199 }
200 Ok(())
201}
202
203fn valid_identifier(value: &str, max_bytes: usize) -> bool {
204 !value.is_empty()
205 && value.len() <= max_bytes
206 && value
207 .chars()
208 .all(|character| character.is_ascii_graphic() && !character.is_ascii_control())
209}
210
211fn to_i64(value: u64, resource: &'static str) -> SqliteSyncResult<i64> {
212 i64::try_from(value).map_err(|_| SqliteSyncError::CapacityExceeded(resource))
213}
214
215fn to_u64(value: i64, resource: &'static str) -> SqliteSyncResult<u64> {
216 u64::try_from(value).map_err(|_| SqliteSyncError::CorruptRecord(resource))
217}