1use crate::{DbError, DbOptions, Result};
13use rusqlite::{Connection, ErrorCode};
14use std::path::{Path, PathBuf};
15use std::time::{SystemTime, UNIX_EPOCH};
16use zeroize::Zeroizing;
17
18#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
19pub struct RecoveryPolicy {
20 pub quarantine_corrupt: bool,
22 pub quarantine_keyed: bool,
24 pub quarantine_stamp: QuarantineStamp,
26 pub sidecars: SidecarPolicy,
29}
30
31#[derive(Clone, Copy, Debug, Default)]
33pub enum QuarantineStamp {
34 #[default]
36 Millis,
37 Seconds,
39 Custom(fn(SystemTime) -> String),
44}
45
46impl PartialEq for QuarantineStamp {
50 fn eq(&self, other: &Self) -> bool {
51 match (self, other) {
52 (Self::Millis, Self::Millis) | (Self::Seconds, Self::Seconds) => true,
53 (Self::Custom(a), Self::Custom(b)) => std::ptr::fn_addr_eq(*a, *b),
54 _ => false,
55 }
56 }
57}
58
59impl Eq for QuarantineStamp {}
60
61impl QuarantineStamp {
62 fn suffix(self, now: SystemTime) -> String {
63 let since_epoch = now.duration_since(UNIX_EPOCH).unwrap_or_default();
64 match self {
65 Self::Millis => format!(".corrupt-{}", since_epoch.as_millis()),
66 Self::Seconds => format!(".corrupt-{}", since_epoch.as_secs()),
67 Self::Custom(format) => format(now),
68 }
69 }
70}
71
72#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
74pub enum SidecarPolicy {
75 #[default]
78 RenameSidecars,
79 DeleteSidecars,
82}
83
84#[derive(Debug)]
85pub struct OpenOutcome {
86 pub conn: Connection,
87 pub key_index: usize,
89 pub quarantined: Option<PathBuf>,
91}
92
93impl DbOptions {
94 pub fn open_writer_with_key_candidates(
96 &self,
97 candidates: &[Zeroizing<String>],
98 ) -> Result<(Connection, usize)> {
99 let mut first_error = None;
100 for (index, candidate) in candidates.iter().enumerate() {
101 let mut attempt = self.clone();
102 attempt.key = Some(candidate.clone());
103 attempt.raw_key = None;
104 match attempt.open_writer() {
105 Ok(conn) => return Ok((conn, index)),
106 Err(error) => {
107 first_error.get_or_insert(error);
108 }
109 }
110 }
111 Err(first_error.unwrap_or(DbError::NoKeyOpened))
112 }
113
114 pub fn open_writer_recovering(&self, policy: RecoveryPolicy) -> Result<OpenOutcome> {
117 match self.open_writer() {
118 Ok(conn) => Ok(OpenOutcome {
119 conn,
120 key_index: 0,
121 quarantined: None,
122 }),
123 Err(error) if is_unreadable(&error) && allowed(self, policy) => {
124 let moved =
125 quarantine_database_with(&self.path, policy.quarantine_stamp, policy.sidecars)?;
126 let conn = self.open_writer()?;
127 Ok(OpenOutcome {
128 conn,
129 key_index: 0,
130 quarantined: Some(moved),
131 })
132 }
133 Err(error) => Err(error),
134 }
135 }
136}
137
138pub(crate) fn allowed(options: &DbOptions, policy: RecoveryPolicy) -> bool {
139 if options.is_keyed() {
140 policy.quarantine_keyed
141 } else {
142 policy.quarantine_corrupt
143 }
144}
145
146pub(crate) fn is_unreadable(error: &DbError) -> bool {
151 match error {
152 DbError::Integrity(_) => true,
153 DbError::Sqlite(rusqlite::Error::SqliteFailure(failure, _)) => matches!(
154 failure.code,
155 ErrorCode::NotADatabase | ErrorCode::DatabaseCorrupt
156 ),
157 _ => false,
158 }
159}
160
161pub fn quarantine_database(db: &Path) -> Result<PathBuf> {
164 quarantine_database_with(db, QuarantineStamp::Millis, SidecarPolicy::RenameSidecars)
165}
166
167pub fn quarantine_database_with(
171 db: &Path,
172 stamp: QuarantineStamp,
173 sidecars: SidecarPolicy,
174) -> Result<PathBuf> {
175 quarantine_database_opts(
176 db,
177 &QuarantineOptions {
178 name: stamp,
179 sidecars,
180 ..QuarantineOptions::default()
181 },
182 )
183}
184
185#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
187pub enum MoveMode {
188 #[default]
190 Rename,
191 HardLinkThenRemove,
195}
196
197#[derive(Clone, Debug, Default)]
199pub struct QuarantineOptions {
200 pub name: QuarantineStamp,
202 pub sidecars: SidecarPolicy,
204 pub move_mode: MoveMode,
206 pub marker: Option<PathBuf>,
210}
211
212const QUARANTINE_NAME_ATTEMPTS: u32 = 100;
215
216pub fn quarantine_database_opts(db: &Path, options: &QuarantineOptions) -> Result<PathBuf> {
225 quarantine_steps(db, options).map_err(|failure| failure.error)
226}
227
228pub(crate) const QUARANTINE_JOURNAL_SUFFIX: &str = ".quarantine-journal";
231
232pub(crate) struct QuarantineFailure {
235 pub(crate) error: DbError,
236 pub(crate) stranded_at: Option<PathBuf>,
237}
238
239impl QuarantineFailure {
240 fn untouched(error: DbError) -> Self {
241 Self {
242 error,
243 stranded_at: None,
244 }
245 }
246}
247
248pub(crate) fn unfinished_quarantine(db: &Path) -> Option<PathBuf> {
251 let journal = with_suffix(db, QUARANTINE_JOURNAL_SUFFIX);
252 std::fs::symlink_metadata(&journal).ok().map(|_| journal)
253}
254
255pub(crate) fn quarantine_database_journaled(
259 db: &Path,
260 stamp: QuarantineStamp,
261 sidecars: SidecarPolicy,
262) -> std::result::Result<PathBuf, QuarantineFailure> {
263 let journal = with_suffix(db, QUARANTINE_JOURNAL_SUFFIX);
264 let result = quarantine_steps(
265 db,
266 &QuarantineOptions {
267 name: stamp,
268 sidecars,
269 marker: Some(journal.clone()),
270 ..QuarantineOptions::default()
271 },
272 );
273 match &result {
274 Err(failure) if failure.stranded_at.is_some() => {}
275 _ => {
276 let _ = std::fs::remove_file(&journal);
277 }
278 }
279 result
280}
281
282struct Moved {
284 from: PathBuf,
285 to: PathBuf,
286}
287
288fn quarantine_steps(
289 db: &Path,
290 options: &QuarantineOptions,
291) -> std::result::Result<PathBuf, QuarantineFailure> {
292 let name = db
293 .file_name()
294 .ok_or(DbError::InvalidDatabasePath)
295 .map_err(QuarantineFailure::untouched)?
296 .to_string_lossy()
297 .into_owned();
298 let suffix = options.name.suffix(SystemTime::now());
299 if suffix.is_empty() || suffix.contains(['/', '\\', '\0']) {
300 return Err(QuarantineFailure::untouched(
301 DbError::InvalidQuarantineSuffix(suffix),
302 ));
303 }
304 let attempts = match options.name {
305 QuarantineStamp::Seconds | QuarantineStamp::Custom(_) => QUARANTINE_NAME_ATTEMPTS,
306 QuarantineStamp::Millis => 1,
307 };
308 let mut last = None;
309 for attempt in 0..attempts {
310 let dest = quarantine_candidate(db, &name, &suffix, attempt);
311 if quarantine_name_taken(&dest, options.sidecars) {
312 last = Some(dest);
313 continue;
314 }
315 if let Some(marker) = &options.marker {
316 write_marker(marker, db, &dest).map_err(QuarantineFailure::untouched)?;
317 }
318 match move_file(db, &dest, options.move_mode) {
319 Ok(()) => {}
320 Err(error)
322 if error.kind() == std::io::ErrorKind::AlreadyExists && attempt + 1 < attempts =>
323 {
324 last = Some(dest);
325 continue;
326 }
327 Err(error) => {
328 return Err(QuarantineFailure::untouched(DbError::Fs(
330 rightkit_fs::FsError::io(db, error),
331 )));
332 }
333 }
334 let mut done = vec![Moved {
335 from: db.to_path_buf(),
336 to: dest.clone(),
337 }];
338 let mut deleted = false;
339 for sidecar in ["-wal", "-shm", "-journal"] {
340 let from = with_suffix(db, sidecar);
341 let step = match options.sidecars {
342 SidecarPolicy::RenameSidecars => {
343 let to = with_suffix(&dest, sidecar);
344 match move_if_present(&from, &to, options.move_mode) {
345 Ok(true) => {
346 done.push(Moved { from, to });
347 Ok(())
348 }
349 Ok(false) => Ok(()),
350 Err(error) => Err(error),
351 }
352 }
353 SidecarPolicy::DeleteSidecars => match remove_if_present(&from) {
354 Ok(removed) => {
355 deleted |= removed;
356 Ok(())
357 }
358 Err(error) => Err(error),
359 },
360 };
361 if let Err(error) = step {
362 return Err(undo_quarantine(db, &dest, done, deleted, error, options));
363 }
364 }
365 return Ok(dest);
366 }
367 let dest = last.unwrap_or_else(|| quarantine_candidate(db, &name, &suffix, 0));
368 Err(QuarantineFailure::untouched(DbError::Fs(
369 rightkit_fs::FsError::io(
370 &dest,
371 std::io::Error::new(
372 std::io::ErrorKind::AlreadyExists,
373 "quarantine destination already exists",
374 ),
375 ),
376 )))
377}
378
379fn undo_quarantine(
383 db: &Path,
384 dest: &Path,
385 done: Vec<Moved>,
386 deleted: bool,
387 error: DbError,
388 options: &QuarantineOptions,
389) -> QuarantineFailure {
390 let stranded = |reason: String| QuarantineFailure {
391 error: DbError::Fs(rightkit_fs::FsError::io(
392 dest,
393 std::io::Error::other(format!(
394 "quarantine incomplete: {error}; {reason}; the store is split between {} and {}",
395 db.display(),
396 dest.display()
397 )),
398 )),
399 stranded_at: Some(dest.to_path_buf()),
400 };
401 if deleted {
402 return stranded("a deleted sidecar cannot be restored, so the move was not undone".into());
403 }
404 for step in done.iter().rev() {
405 if let Err(e) = unmove_file(&step.to, &step.from, options.move_mode) {
406 return stranded(format!("rolling back {} failed: {e}", step.from.display()));
407 }
408 }
409 if let Some(marker) = &options.marker {
410 let _ = std::fs::remove_file(marker);
411 }
412 QuarantineFailure::untouched(error)
413}
414
415fn unmove_file(to: &Path, from: &Path, mode: MoveMode) -> std::io::Result<()> {
417 move_file(to, from, mode)
418}
419
420fn quarantine_candidate(db: &Path, name: &str, suffix: &str, attempt: u32) -> PathBuf {
422 if attempt == 0 {
423 db.with_file_name(format!("{name}{suffix}"))
424 } else {
425 db.with_file_name(format!("{name}{suffix}-{attempt}"))
426 }
427}
428
429fn quarantine_name_taken(dest: &Path, sidecars: SidecarPolicy) -> bool {
432 fn exists(path: &Path) -> bool {
433 std::fs::symlink_metadata(path).is_ok()
434 }
435 exists(dest)
436 || (sidecars == SidecarPolicy::RenameSidecars
437 && ["-wal", "-shm", "-journal"]
438 .iter()
439 .any(|sidecar| exists(&with_suffix(dest, sidecar))))
440}
441
442fn move_file(from: &Path, to: &Path, mode: MoveMode) -> std::io::Result<()> {
445 match mode {
446 MoveMode::Rename => rightkit_fs::rename_exclusive(from, to).map_err(std::io::Error::from),
447 MoveMode::HardLinkThenRemove => {
448 std::fs::hard_link(from, to)?;
449 std::fs::remove_file(from)
450 }
451 }
452}
453
454fn move_if_present(from: &Path, to: &Path, mode: MoveMode) -> Result<bool> {
456 match move_file(from, to, mode) {
457 Ok(()) => Ok(true),
458 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
459 Err(error) => Err(DbError::Fs(rightkit_fs::FsError::io(from, error))),
460 }
461}
462
463fn write_marker(marker: &Path, original: &Path, quarantined: &Path) -> Result<()> {
464 let unix_ms = SystemTime::now()
465 .duration_since(UNIX_EPOCH)
466 .unwrap_or_default()
467 .as_millis();
468 let body = format!(
469 "{{\"schema\":\"rightkit-db-quarantine.v1\",\"original\":{},\"quarantined\":{},\"unixMs\":{}}}\n",
470 json_string(&original.to_string_lossy()),
471 json_string(&quarantined.to_string_lossy()),
472 unix_ms
473 );
474 rightkit_fs::atomic_write_private(marker, body.as_bytes())?;
475 Ok(())
476}
477
478fn json_string(value: &str) -> String {
480 let mut out = String::with_capacity(value.len() + 2);
481 out.push('"');
482 for c in value.chars() {
483 match c {
484 '"' => out.push_str("\\\""),
485 '\\' => out.push_str("\\\\"),
486 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
487 c => out.push(c),
488 }
489 }
490 out.push('"');
491 out
492}
493
494fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
495 let mut name = path.as_os_str().to_os_string();
496 name.push(suffix);
497 PathBuf::from(name)
498}
499
500fn remove_if_present(path: &Path) -> Result<bool> {
502 match std::fs::remove_file(path) {
503 Ok(()) => Ok(true),
504 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
505 Err(error) => Err(DbError::Fs(rightkit_fs::FsError::io(path, error))),
506 }
507}
508
509#[cfg(feature = "cipher")]
514pub fn rekey(conn: &Connection, new_key: &str) -> Result<()> {
515 crate::validate_key(Some(new_key))?;
516 let statement = Zeroizing::new(format!("PRAGMA rekey = '{new_key}';"));
517 conn.execute_batch(statement.as_str())?;
518 crate::verify_key(conn)
519}