1use std::collections::BTreeSet;
6
7use time::OffsetDateTime;
8use time::format_description::well_known::Rfc3339;
9
10use super::{Kept, Store, StoreError, Unopened, due, storage_failed};
11use crate::backend::ObjectError;
12use crate::clock::from_nanos;
13use crate::ids::VersionId;
14use crate::retention::Retention;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Examination {
19 pub versions: u64,
21 pub packed: u64,
23 pub damaged: Vec<VersionId>,
25 pub leftovers: Vec<String>,
28 pub due: u64,
30 pub oldest_due_since: Option<OffsetDateTime>,
32 pub purges_since_key: u64,
35 pub oldest_purge_since_key: Option<OffsetDateTime>,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum ExamineError {
42 Objects {
47 kind: std::io::ErrorKind,
48 os_error: Option<i32>,
49 },
50 Failed(StoreError),
52}
53
54impl From<StoreError> for ExamineError {
55 fn from(e: StoreError) -> ExamineError {
56 ExamineError::Failed(e)
57 }
58}
59
60impl ExamineError {
61 fn storage(e: &std::io::Error) -> ExamineError {
63 storage_failed(e);
64 ExamineError::Objects {
65 kind: e.kind(),
66 os_error: e.raw_os_error(),
67 }
68 }
69}
70
71fn unlisted(e: ObjectError) -> ExamineError {
74 match e {
75 ObjectError::Io(e) => ExamineError::storage(&e),
76 ObjectError::Missing => ExamineError::Failed(StoreError::Io),
77 }
78}
79
80impl Store {
81 pub fn examine(
84 &self,
85 retention: Retention,
86 key_since: OffsetDateTime,
87 now: OffsetDateTime,
88 ) -> Result<Examination, ExamineError> {
89 let mut tx = self.catalog.snapshot()?;
90 let rows = tx.examined()?;
91 let time = |nanos| from_nanos(nanos).ok_or(StoreError::Damaged);
92 let mut found = Examination {
93 versions: rows.len() as u64,
94 packed: tx.count_packed()?,
95 damaged: Vec::new(),
96 leftovers: Vec::new(),
97 due: 0,
98 oldest_due_since: None,
99 purges_since_key: 0,
100 oldest_purge_since_key: None,
101 };
102 for row in &rows {
103 let (version, stored_at, held) = (row.version, row.stored_at, row.held);
104 match self.read_or_why(&mut tx, row.uuid, version) {
105 Ok(_) => {}
106 Err(Unopened::Store(StoreError::Damaged)) => found.damaged.push(version),
107 Err(Unopened::Store(e)) => return Err(e.into()),
108 Err(Unopened::Storage(e)) => return Err(ExamineError::storage(&e)),
109 }
110 let Some(hidden_at) = row.hidden_at else {
111 continue;
112 };
113 let stored_at = time(stored_at)?;
114 let kept = Kept {
115 stored_at,
116 hidden: true,
117 held,
118 };
119 if !due(&kept, retention, now) {
120 continue;
121 }
122 let end = retention.after(stored_at).ok_or(StoreError::Damaged)?;
125 let since = end.max(time(hidden_at)?);
126 found.due += 1;
127 found.oldest_due_since = Some(found.oldest_due_since.map_or(since, |o| o.min(since)));
128 }
129 let named: BTreeSet<String> = tx
132 .places()?
133 .into_iter()
134 .filter(|(_, place)| super::names_its_object(place))
135 .map(|(version, _)| version.to_string())
136 .collect();
137 for name in self.objects.list().map_err(unlisted)? {
138 let name = name.as_str();
139 let (id, temporary) = match name.strip_suffix(".tmp") {
140 Some(id) => (id, true),
141 None => (name, false),
142 };
143 if VersionId::parse(id).is_some() && (temporary || !named.contains(id)) {
144 found.leftovers.push(name.to_owned());
145 }
146 }
147 found.leftovers.sort();
148 drop(tx);
149 (found.purges_since_key, found.oldest_purge_since_key) = self.purges_since(key_since)?;
150 Ok(found)
151 }
152
153 pub fn purges_since(
156 &self,
157 since: OffsetDateTime,
158 ) -> Result<(u64, Option<OffsetDateTime>), StoreError> {
159 let purged = self.catalog.snapshot()?.purge_times()?;
160 let (mut count, mut oldest) = (0, None::<OffsetDateTime>);
161 for at in purged {
162 let at = OffsetDateTime::parse(&at, &Rfc3339).map_err(|_| StoreError::Damaged)?;
163 if at >= since {
164 count += 1;
165 oldest = Some(oldest.map_or(at, |o| o.min(at)));
166 }
167 }
168 Ok((count, oldest))
169 }
170}
171
172#[cfg(test)]
173mod examine_tests {
174 use time::macros::datetime;
175
176 use super::super::testing::vcon;
177 use super::super::tests::{Fixture, fixture};
178 use super::*;
179 use crate::fs::Fs;
180 use crate::store::Created;
181
182 const START: OffsetDateTime = datetime!(2026-10-23 12:00 UTC);
183
184 fn stored(f: &Fixture, n: u8) -> (uuid::Uuid, VersionId) {
185 let (found, body) = vcon(&format!("01a07da8-c2bb-83e5-b9a2-279e0d16bc4{n}"), "kept");
186 let Ok(Created::New(version)) = f.store.create(&f.caller, &found, &body) else {
187 panic!("a new vCon")
188 };
189 (found.uuid, version)
190 }
191
192 fn examine(f: &Fixture, retention: Retention, now: OffsetDateTime) -> Examination {
193 f.store.examine(retention, START, now).expect("examined")
194 }
195
196 #[test]
197 fn a_sound_store_reports_its_versions_and_nothing_else() {
198 let f = fixture();
199 stored(&f, 0);
200 stored(&f, 1);
201 assert_eq!(
202 examine(&f, Retention::Years(7), START),
203 Examination {
204 versions: 2,
205 packed: 0,
206 damaged: vec![],
207 leftovers: vec![],
208 due: 0,
209 oldest_due_since: None,
210 purges_since_key: 0,
211 oldest_purge_since_key: None,
212 }
213 );
214 }
215
216 #[test]
217 fn a_missing_or_unreadable_object_is_damage() {
218 let f = fixture();
219 let (_, missing) = stored(&f, 0);
220 let (_, altered) = stored(&f, 1);
221 stored(&f, 2);
222 let objects = f.store.objects_dir.clone();
223 f.fs.remove(&objects.join(missing.to_string()))
224 .expect("remove");
225 f.fs.remove(&objects.join(altered.to_string()))
226 .expect("remove");
227 f.fs.write_new(&objects.join(altered.to_string()), b"not a sealed body")
228 .expect("write");
229 let mut expected = vec![missing, altered];
230 expected.sort_by_key(|v| v.0);
231 assert_eq!(examine(&f, Retention::Years(7), START).damaged, expected);
232 }
233
234 #[test]
235 fn files_no_row_names_are_leftovers_and_foreign_files_are_not_ours() {
236 let f = fixture();
237 let (_, version) = stored(&f, 0);
238 let orphan = VersionId([0x0e; 16]);
239 let objects = f.store.objects_dir.clone();
240 for name in [
241 orphan.to_string(),
242 format!("{orphan}.tmp"),
243 format!("{version}.tmp"),
244 "notes.txt".to_owned(),
245 ] {
246 f.fs.write_new(&objects.join(name), b"x").expect("write");
247 }
248 let mut expected = vec![
249 orphan.to_string(),
250 format!("{orphan}.tmp"),
251 format!("{version}.tmp"),
252 ];
253 expected.sort();
254 let found = examine(&f, Retention::Years(7), START);
255 assert_eq!(found.leftovers, expected);
256 assert_eq!(found.damaged, Vec::<VersionId>::new());
257 }
258
259 #[test]
260 fn versions_due_are_counted_from_when_each_fell_due_and_a_hold_keeps_them() {
261 let f = fixture();
262 let (deleted_early, _) = stored(&f, 0);
263 let (deleted_late, _) = stored(&f, 1);
264 let (held, _) = stored(&f, 2);
265 stored(&f, 3);
266 f.store
267 .delete(&f.caller, deleted_early, Retention::Years(7))
268 .expect("deleted");
269 f.store
270 .delete(&f.caller, held, Retention::Years(7))
271 .expect("deleted");
272 f.store.set_hold(&f.caller, held, true).expect("held");
273 f.clock.set(datetime!(2026-10-26 09:00 UTC));
275 f.store
276 .delete(&f.caller, deleted_late, Retention::Years(7))
277 .expect("deleted");
278 let found = examine(&f, Retention::Days(1), datetime!(2026-10-27 12:00 UTC));
279 assert_eq!(found.due, 2, "the held one is not due");
280 assert_eq!(
281 found.oldest_due_since,
282 Some(datetime!(2026-10-24 12:00 UTC)),
283 "a day after it was stored"
284 );
285 let later = examine(&f, Retention::Years(7), datetime!(2026-10-27 12:00 UTC));
286 assert_eq!((later.due, later.oldest_due_since), (0, None));
287 assert_eq!(later.versions, 4);
288 }
289
290 #[test]
293 fn a_version_hidden_late_is_due_from_its_hiding() {
294 let f = fixture();
295 let (uuid, _) = stored(&f, 0);
296 f.clock.set(datetime!(2026-10-26 09:00 UTC));
297 f.store
298 .delete(&f.caller, uuid, Retention::Years(7))
299 .expect("deleted");
300 let found = examine(&f, Retention::Days(1), datetime!(2026-10-27 12:00 UTC));
301 assert_eq!(found.due, 1);
302 assert_eq!(
303 found.oldest_due_since,
304 Some(datetime!(2026-10-26 09:00 UTC))
305 );
306 }
307
308 #[test]
309 fn purges_since_the_key_was_rotated_are_counted() {
310 let f = fixture();
311 let (first, _) = stored(&f, 0);
312 let (second, _) = stored(&f, 1);
313 f.clock.set(datetime!(2026-10-24 12:00 UTC));
314 f.store
315 .delete(&f.caller, first, Retention::Days(0))
316 .expect("deleted and purged");
317 f.clock.set(datetime!(2026-10-25 12:00 UTC));
318 f.store
319 .delete(&f.caller, second, Retention::Days(0))
320 .expect("deleted and purged");
321 f.store
323 .conn()
324 .execute(
325 "INSERT INTO audit (at, actor, action, target, version, status, count)
326 VALUES ('2026-10-23T13:00:00Z', 'sweep', 'purge', NULL, NULL, 500, 1)",
327 [],
328 )
329 .expect("a failed purge's record");
330 let now = datetime!(2026-10-26 12:00 UTC);
331 let all = f
332 .store
333 .examine(Retention::Days(0), START, now)
334 .expect("examined");
335 assert_eq!(all.purges_since_key, 2);
336 assert_eq!(
337 all.oldest_purge_since_key,
338 Some(datetime!(2026-10-24 12:00 UTC))
339 );
340 let since = f
341 .store
342 .examine(Retention::Days(0), datetime!(2026-10-25 00:00 UTC), now)
343 .expect("examined");
344 assert_eq!(since.purges_since_key, 1);
345 assert_eq!(
346 since.oldest_purge_since_key,
347 Some(datetime!(2026-10-25 12:00 UTC))
348 );
349 assert_eq!(since.versions, 0);
350 }
351}