use crate::{DbError, DbOptions, Result};
use rusqlite::{Connection, ErrorCode};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use zeroize::Zeroizing;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct RecoveryPolicy {
pub quarantine_corrupt: bool,
pub quarantine_keyed: bool,
pub quarantine_stamp: QuarantineStamp,
pub sidecars: SidecarPolicy,
pub allow_in_memory_fallback: bool,
}
#[derive(Clone, Copy, Debug, Default)]
pub enum QuarantineStamp {
#[default]
Millis,
Seconds,
Custom(fn(SystemTime) -> String),
}
impl PartialEq for QuarantineStamp {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Millis, Self::Millis) | (Self::Seconds, Self::Seconds) => true,
(Self::Custom(a), Self::Custom(b)) => std::ptr::fn_addr_eq(*a, *b),
_ => false,
}
}
}
impl Eq for QuarantineStamp {}
impl QuarantineStamp {
fn suffix(self, now: SystemTime) -> String {
let since_epoch = now.duration_since(UNIX_EPOCH).unwrap_or_default();
match self {
Self::Millis => format!(".corrupt-{}", since_epoch.as_millis()),
Self::Seconds => format!(".corrupt-{}", since_epoch.as_secs()),
Self::Custom(format) => format(now),
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum SidecarPolicy {
#[default]
RenameSidecars,
DeleteSidecars,
}
#[derive(Debug)]
pub struct OpenOutcome {
pub conn: Connection,
pub key_index: usize,
pub quarantined: Option<PathBuf>,
}
impl DbOptions {
pub fn open_writer_with_key_candidates(
&self,
candidates: &[Zeroizing<String>],
) -> Result<(Connection, usize)> {
let mut first_error = None;
for (index, candidate) in candidates.iter().enumerate() {
let mut attempt = self.clone();
attempt.key = Some(candidate.clone());
attempt.raw_key = None;
match attempt.open_writer() {
Ok(conn) => return Ok((conn, index)),
Err(error) => {
first_error.get_or_insert(error);
}
}
}
Err(first_error.unwrap_or(DbError::NoKeyOpened))
}
pub fn open_writer_recovering(&self, policy: RecoveryPolicy) -> Result<OpenOutcome> {
match self.open_writer() {
Ok(conn) => Ok(OpenOutcome {
conn,
key_index: 0,
quarantined: None,
}),
Err(error) if is_unreadable(&error) && allowed(self, policy) => {
let moved =
quarantine_database_with(&self.path, policy.quarantine_stamp, policy.sidecars)?;
let conn = self.open_writer()?;
Ok(OpenOutcome {
conn,
key_index: 0,
quarantined: Some(moved),
})
}
Err(error) => Err(error),
}
}
}
pub(crate) fn allowed(options: &DbOptions, policy: RecoveryPolicy) -> bool {
if options.is_keyed() {
policy.quarantine_keyed
} else {
policy.quarantine_corrupt
}
}
pub(crate) fn is_unreadable(error: &DbError) -> bool {
match error {
DbError::Integrity(_) => true,
DbError::Sqlite(rusqlite::Error::SqliteFailure(failure, _)) => matches!(
failure.code,
ErrorCode::NotADatabase | ErrorCode::DatabaseCorrupt
),
_ => false,
}
}
pub fn quarantine_database(db: &Path) -> Result<PathBuf> {
quarantine_database_with(db, QuarantineStamp::Millis, SidecarPolicy::RenameSidecars)
}
pub fn quarantine_database_with(
db: &Path,
stamp: QuarantineStamp,
sidecars: SidecarPolicy,
) -> Result<PathBuf> {
quarantine_database_opts(
db,
&QuarantineOptions {
name: stamp,
sidecars,
..QuarantineOptions::default()
},
)
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum MoveMode {
#[default]
Rename,
HardLinkThenRemove,
}
#[derive(Clone, Debug, Default)]
pub struct QuarantineOptions {
pub name: QuarantineStamp,
pub sidecars: SidecarPolicy,
pub move_mode: MoveMode,
pub marker: Option<PathBuf>,
}
const QUARANTINE_NAME_ATTEMPTS: u32 = 100;
pub fn quarantine_database_opts(db: &Path, options: &QuarantineOptions) -> Result<PathBuf> {
quarantine_steps(db, options).map_err(|failure| failure.error)
}
pub(crate) const QUARANTINE_JOURNAL_SUFFIX: &str = ".quarantine-journal";
pub(crate) struct QuarantineFailure {
pub(crate) error: DbError,
pub(crate) stranded_at: Option<PathBuf>,
}
impl QuarantineFailure {
fn untouched(error: DbError) -> Self {
Self {
error,
stranded_at: None,
}
}
}
pub(crate) fn unfinished_quarantine(db: &Path) -> Option<PathBuf> {
let journal = with_suffix(db, QUARANTINE_JOURNAL_SUFFIX);
std::fs::symlink_metadata(&journal).ok().map(|_| journal)
}
#[allow(clippy::result_large_err)]
pub(crate) fn quarantine_database_journaled(
db: &Path,
stamp: QuarantineStamp,
sidecars: SidecarPolicy,
) -> std::result::Result<PathBuf, QuarantineFailure> {
let journal = with_suffix(db, QUARANTINE_JOURNAL_SUFFIX);
let result = quarantine_steps(
db,
&QuarantineOptions {
name: stamp,
sidecars,
marker: Some(journal.clone()),
..QuarantineOptions::default()
},
);
match &result {
Err(failure) if failure.stranded_at.is_some() => {}
_ => {
let _ = std::fs::remove_file(&journal);
}
}
result
}
struct Moved {
from: PathBuf,
to: PathBuf,
}
#[allow(clippy::result_large_err)]
fn quarantine_steps(
db: &Path,
options: &QuarantineOptions,
) -> std::result::Result<PathBuf, QuarantineFailure> {
let name = db
.file_name()
.ok_or(DbError::InvalidDatabasePath)
.map_err(QuarantineFailure::untouched)?
.to_string_lossy()
.into_owned();
let suffix = options.name.suffix(SystemTime::now());
if suffix.is_empty() || suffix.contains(['/', '\\', '\0']) {
return Err(QuarantineFailure::untouched(
DbError::InvalidQuarantineSuffix(suffix),
));
}
let attempts = match options.name {
QuarantineStamp::Seconds | QuarantineStamp::Custom(_) => QUARANTINE_NAME_ATTEMPTS,
QuarantineStamp::Millis => 1,
};
let mut last = None;
for attempt in 0..attempts {
let dest = quarantine_candidate(db, &name, &suffix, attempt);
if quarantine_name_taken(&dest, options.sidecars) {
last = Some(dest);
continue;
}
if let Some(marker) = &options.marker {
write_marker(marker, db, &dest).map_err(QuarantineFailure::untouched)?;
}
match move_file(db, &dest, options.move_mode) {
Ok(()) => {}
Err(error)
if error.kind() == std::io::ErrorKind::AlreadyExists && attempt + 1 < attempts =>
{
last = Some(dest);
continue;
}
Err(error) => {
return Err(QuarantineFailure::untouched(DbError::Fs(
rightkit_fs::FsError::io(db, error),
)));
}
}
let mut done = vec![Moved {
from: db.to_path_buf(),
to: dest.clone(),
}];
let mut deleted = false;
for sidecar in ["-wal", "-shm", "-journal"] {
let from = with_suffix(db, sidecar);
let step = match options.sidecars {
SidecarPolicy::RenameSidecars => {
let to = with_suffix(&dest, sidecar);
match move_if_present(&from, &to, options.move_mode) {
Ok(true) => {
done.push(Moved { from, to });
Ok(())
}
Ok(false) => Ok(()),
Err(error) => Err(error),
}
}
SidecarPolicy::DeleteSidecars => match remove_if_present(&from) {
Ok(removed) => {
deleted |= removed;
Ok(())
}
Err(error) => Err(error),
},
};
if let Err(error) = step {
return Err(undo_quarantine(db, &dest, done, deleted, error, options));
}
}
return Ok(dest);
}
let dest = last.unwrap_or_else(|| quarantine_candidate(db, &name, &suffix, 0));
Err(QuarantineFailure::untouched(DbError::Fs(
rightkit_fs::FsError::io(
&dest,
std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"quarantine destination already exists",
),
),
)))
}
fn undo_quarantine(
db: &Path,
dest: &Path,
done: Vec<Moved>,
deleted: bool,
error: DbError,
options: &QuarantineOptions,
) -> QuarantineFailure {
let stranded = |reason: String| QuarantineFailure {
error: DbError::Fs(rightkit_fs::FsError::io(
dest,
std::io::Error::other(format!(
"quarantine incomplete: {error}; {reason}; the store is split between {} and {}",
db.display(),
dest.display()
)),
)),
stranded_at: Some(dest.to_path_buf()),
};
if deleted {
return stranded("a deleted sidecar cannot be restored, so the move was not undone".into());
}
for step in done.iter().rev() {
if let Err(e) = unmove_file(&step.to, &step.from, options.move_mode) {
return stranded(format!("rolling back {} failed: {e}", step.from.display()));
}
}
if let Some(marker) = &options.marker {
let _ = std::fs::remove_file(marker);
}
QuarantineFailure::untouched(error)
}
fn unmove_file(to: &Path, from: &Path, mode: MoveMode) -> std::io::Result<()> {
move_file(to, from, mode)
}
fn quarantine_candidate(db: &Path, name: &str, suffix: &str, attempt: u32) -> PathBuf {
if attempt == 0 {
db.with_file_name(format!("{name}{suffix}"))
} else {
db.with_file_name(format!("{name}{suffix}-{attempt}"))
}
}
fn quarantine_name_taken(dest: &Path, sidecars: SidecarPolicy) -> bool {
fn exists(path: &Path) -> bool {
std::fs::symlink_metadata(path).is_ok()
}
exists(dest)
|| (sidecars == SidecarPolicy::RenameSidecars
&& ["-wal", "-shm", "-journal"]
.iter()
.any(|sidecar| exists(&with_suffix(dest, sidecar))))
}
fn move_file(from: &Path, to: &Path, mode: MoveMode) -> std::io::Result<()> {
match mode {
MoveMode::Rename => rightkit_fs::rename_exclusive(from, to).map_err(std::io::Error::from),
MoveMode::HardLinkThenRemove => {
std::fs::hard_link(from, to)?;
std::fs::remove_file(from)
}
}
}
fn move_if_present(from: &Path, to: &Path, mode: MoveMode) -> Result<bool> {
match move_file(from, to, mode) {
Ok(()) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(DbError::Fs(rightkit_fs::FsError::io(from, error))),
}
}
fn write_marker(marker: &Path, original: &Path, quarantined: &Path) -> Result<()> {
let unix_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let body = format!(
"{{\"schema\":\"rightkit-db-quarantine.v1\",\"original\":{},\"quarantined\":{},\"unixMs\":{}}}\n",
json_string(&original.to_string_lossy()),
json_string(&quarantined.to_string_lossy()),
unix_ms
);
rightkit_fs::atomic_write_private(marker, body.as_bytes())?;
Ok(())
}
fn json_string(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for c in value.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
let mut name = path.as_os_str().to_os_string();
name.push(suffix);
PathBuf::from(name)
}
fn remove_if_present(path: &Path) -> Result<bool> {
match std::fs::remove_file(path) {
Ok(()) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(DbError::Fs(rightkit_fs::FsError::io(path, error))),
}
}
#[cfg(feature = "cipher")]
pub fn rekey(conn: &Connection, new_key: &str) -> Result<()> {
crate::validate_key(Some(new_key))?;
let statement = Zeroizing::new(format!("PRAGMA rekey = '{new_key}';"));
conn.execute_batch(statement.as_str())?;
crate::verify_key(conn)
}