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