1use crate::storage::unified::UnifiedStore;
18use crate::{RedDBError, RedDBResult};
19use reddb_types::Value;
20
21pub const TOMBSTONE_KEY: &str = "stream.integrity.tombstones";
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum VerifyMode {
30 #[default]
31 None,
32 Sha256,
33}
34
35impl VerifyMode {
36 pub fn parse(token: &str) -> VerifyMode {
39 match token.trim().to_ascii_lowercase().as_str() {
40 "sha256" => VerifyMode::Sha256,
41 _ => VerifyMode::None,
42 }
43 }
44
45 pub fn is_enabled(self) -> bool {
46 matches!(self, VerifyMode::Sha256)
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct TombstoneRange {
56 pub table: String,
57 pub lo: u64,
58 pub hi: u64,
59}
60
61impl TombstoneRange {
62 pub fn new(table: impl Into<String>, lo: u64, hi: u64) -> Self {
63 Self {
64 table: table.into(),
65 lo,
66 hi,
67 }
68 }
69
70 pub fn covers_rid(&self, rid: u64) -> bool {
77 self.lo <= rid && rid <= self.hi
78 }
79}
80
81pub fn serialize_ranges(ranges: &[TombstoneRange]) -> String {
86 let mut out = String::with_capacity(2 + ranges.len() * 40);
87 out.push('[');
88 for (i, r) in ranges.iter().enumerate() {
89 if i > 0 {
90 out.push(',');
91 }
92 out.push_str("{\"table\":\"");
93 for ch in r.table.chars() {
94 if ch == '"' || ch == '\\' {
95 out.push('\\');
96 }
97 out.push(ch);
98 }
99 out.push_str(&format!("\",\"lo\":{},\"hi\":{}}}", r.lo, r.hi));
100 }
101 out.push(']');
102 out
103}
104
105pub fn parse_ranges(json: &str) -> RedDBResult<Vec<TombstoneRange>> {
108 let invalid = || RedDBError::Query("invalid persisted stream integrity tombstones".into());
109 let value: crate::json::Value =
110 crate::json::from_slice(json.as_bytes()).map_err(|_| invalid())?;
111 let arr = value.as_array().ok_or_else(invalid)?;
112 let mut out = Vec::with_capacity(arr.len());
113 for entry in arr {
114 let table = entry
115 .get("table")
116 .and_then(crate::json::Value::as_str)
117 .ok_or_else(invalid)?;
118 let lo = entry
119 .get("lo")
120 .and_then(parse_range_bound)
121 .ok_or_else(invalid)?;
122 let hi = entry
123 .get("hi")
124 .and_then(parse_range_bound)
125 .ok_or_else(invalid)?;
126 if lo > hi {
127 return Err(invalid());
128 }
129 out.push(TombstoneRange::new(table, lo, hi));
130 }
131 Ok(out)
132}
133
134fn parse_range_bound(value: &crate::json::Value) -> Option<u64> {
137 match value {
138 crate::json::Value::Integer(value) => u64::try_from(*value).ok(),
139 crate::json::Value::Decimal(value) => value.parse().ok(),
140 _ => None,
141 }
142}
143
144pub fn load_ranges(store: &UnifiedStore) -> RedDBResult<Vec<TombstoneRange>> {
146 match store.try_get_config(TOMBSTONE_KEY)? {
147 None => Ok(Vec::new()),
148 Some(Value::Text(json)) => parse_ranges(&json),
149 Some(_) => Err(RedDBError::Query(
150 "invalid persisted stream integrity tombstones".into(),
151 )),
152 }
153}
154
155pub fn persist_ranges(store: &UnifiedStore, ranges: &[TombstoneRange]) -> RedDBResult<()> {
157 let json = serialize_ranges(ranges);
158 store.try_set_config_tree(TOMBSTONE_KEY, &crate::serde_json::Value::String(json))?;
159 Ok(())
160}
161
162pub fn record_rid(record: &crate::storage::query::unified::UnifiedRecord) -> Option<u64> {
166 match record.get("rid")? {
167 Value::Integer(v) if *v >= 0 => Some(*v as u64),
168 Value::UnsignedInteger(v) => Some(*v),
169 _ => None,
170 }
171}
172
173pub fn record_tombstoned(
178 ranges: &[TombstoneRange],
179 record: &crate::storage::query::unified::UnifiedRecord,
180) -> bool {
181 let Some(rid) = record_rid(record) else {
182 return false;
183 };
184 ranges.iter().any(|r| r.covers_rid(rid))
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 #[test]
192 fn serialize_parse_round_trip() {
193 let ranges = vec![
194 TombstoneRange::new("orders", 10, 12),
195 TombstoneRange::new("events", 5, 5),
196 ];
197 let json = serialize_ranges(&ranges);
198 let parsed = parse_ranges(&json).expect("valid ranges");
199 assert_eq!(parsed, ranges);
200 }
201
202 #[test]
203 fn parse_rejects_malformed_entries_without_losing_other_ranges() {
204 let json = r#"[{"table":"a","lo":1,"hi":3},{"table":"b","lo":4}]"#;
205 assert!(parse_ranges(json).is_err());
206 assert!(parse_ranges(r#"[{"table":"a","lo":3,"hi":1}]"#).is_err());
207 }
208
209 #[test]
210 fn parse_garbage_is_an_error() {
211 assert!(parse_ranges("not json").is_err());
212 assert!(parse_ranges("{}").is_err());
213 assert!(parse_ranges("[]").expect("empty list").is_empty());
214 }
215
216 #[test]
217 fn covers_rid_is_inclusive() {
218 let r = TombstoneRange::new("t", 4, 6);
219 assert!(r.covers_rid(4));
220 assert!(r.covers_rid(5));
221 assert!(r.covers_rid(6));
222 assert!(!r.covers_rid(3));
223 assert!(!r.covers_rid(7));
224 }
225
226 #[test]
227 fn verify_mode_parse_is_lenient() {
228 assert_eq!(VerifyMode::parse("sha256"), VerifyMode::Sha256);
229 assert_eq!(VerifyMode::parse("SHA256"), VerifyMode::Sha256);
230 assert_eq!(VerifyMode::parse("none"), VerifyMode::None);
231 assert_eq!(VerifyMode::parse(""), VerifyMode::None);
232 assert_eq!(VerifyMode::parse("bogus"), VerifyMode::None);
233 assert!(VerifyMode::Sha256.is_enabled());
234 assert!(!VerifyMode::None.is_enabled());
235 }
236
237 #[test]
238 fn table_name_with_quote_is_escaped() {
239 let ranges = vec![TombstoneRange::new("a\"b", 1, 2)];
242 let json = serialize_ranges(&ranges);
243 let parsed = parse_ranges(&json).expect("valid ranges");
244 assert_eq!(parsed, ranges);
245 }
246 #[test]
247 fn integrity_exact_numeric_ranges_reject_fractional_and_overflowed_ids() {
248 for rid in [(1_u64 << 53) + 1, (i64::MAX as u64) + 1, u64::MAX] {
249 let expected = vec![TombstoneRange::new("events", rid, rid)];
250 assert_eq!(
251 parse_ranges(&serialize_ranges(&expected)).expect("exact integer bounds"),
252 expected
253 );
254 }
255 for invalid in [
256 r#"[{"table":"events","lo":1.5,"hi":2}]"#,
257 r#"[{"table":"events","lo":1,"hi":2.5}]"#,
258 r#"[{"table":"events","lo":-1,"hi":2}]"#,
259 r#"[{"table":"events","lo":1,"hi":18446744073709551616}]"#,
260 ] {
261 assert!(
262 parse_ranges(invalid).is_err(),
263 "invalid RID bounds: {invalid}"
264 );
265 }
266 }
267}