Skip to main content

rightkit_db/
unreadable.rs

1//! Opt-in recovery for a database that cannot be *read*, as opposed to one that is corrupt.
2//!
3//! A permission-denied or I/O read failure is not evidence of corruption. A transient lock, a
4//! network share that dropped, a full disk or the wrong mount would all look the same, and
5//! setting the file aside would replace a healthy database with an empty one. So the default is
6//! [`UnreadablePolicy::Fail`]: a typed [`DbError::Unreadable`] naming the cause, and the file is
7//! never touched. An application that wants to get unstuck chooses
8//! [`UnreadablePolicy::QuarantineAfter`]:
9//!
10//! - only failures whose cause is a *read failure of this file* ([`UnreadableCause::PermissionDenied`],
11//!   [`UnreadableCause::IoReadFailure`]) count;
12//! - failures that say the *storage* is unavailable ([`UnreadableCause::StorageUnavailable`]: disk
13//!   full, read-only filesystem, device or share not ready, sharing or lock violations, busy or
14//!   locked, file missing) never count and never lead to quarantine, however often they repeat;
15//! - the count is persisted beside the database (`<db>.unreadable-opens`) and is *consecutive*:
16//!   any successful open removes it;
17//! - at the configured count the file is renamed aside with its sidecars (never deleted, never
18//!   overwritten) and a fresh database is created.
19//!
20//! The corruption path ([`RecoveryPolicy`]) is unchanged and still decides what happens to a
21//! database that SQLite reports as not-a-database or corrupt.
22
23use crate::recover::{allowed, is_unreadable, quarantine_database_with};
24use crate::recover::{OpenOutcome, RecoveryPolicy, SidecarPolicy};
25use crate::{DbError, DbOptions, Result};
26use rusqlite::{Connection, ErrorCode};
27use std::fmt;
28use std::path::{Path, PathBuf};
29
30/// What to do with a database that cannot be read (not corrupt: see the module docs).
31#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
32#[non_exhaustive]
33pub enum UnreadablePolicy {
34    /// Return [`DbError::Unreadable`]; never touch the file. The default.
35    #[default]
36    Fail,
37    /// Quarantine the file once reading it has failed `attempts` consecutive opens (counting the
38    /// current one; a value of 0 is treated as 1). Three or more is sensible: it means the
39    /// failure survived at least two restarts.
40    QuarantineAfter { attempts: u32 },
41}
42
43/// Why a database could not be read.
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
45#[non_exhaustive]
46pub enum UnreadableCause {
47    /// The operating system refused to let this process read the file.
48    PermissionDenied,
49    /// The operating system or SQLite reported a read error (`EIO`, a short read, a bad sector).
50    IoReadFailure,
51    /// The storage itself is unavailable; the file may be perfectly healthy.
52    StorageUnavailable(StorageProblem),
53}
54
55impl UnreadableCause {
56    /// True for the causes that count toward [`UnreadablePolicy::QuarantineAfter`].
57    pub fn counts_toward_quarantine(self) -> bool {
58        matches!(self, Self::PermissionDenied | Self::IoReadFailure)
59    }
60}
61
62/// Why storage is unavailable. None of these ever leads to quarantine.
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64#[non_exhaustive]
65pub enum StorageProblem {
66    /// `ENOSPC`, `EDQUOT`, `SQLITE_FULL`, Windows `ERROR_DISK_FULL`/`ERROR_HANDLE_DISK_FULL`.
67    DiskFull,
68    /// `EROFS`, `SQLITE_READONLY`, Windows `ERROR_WRITE_PROTECT`.
69    ReadOnlyFilesystem,
70    /// `ENXIO`, `ENODEV`, `ENOMEDIUM`, `ESTALE`, `ENOTCONN`, `ENETDOWN`, `ENETUNREACH`,
71    /// `EHOSTUNREACH`, `ETIMEDOUT`; Windows `ERROR_NOT_READY`, `ERROR_DEV_NOT_EXIST`,
72    /// `ERROR_DEVICE_NOT_CONNECTED`, `ERROR_BAD_NETPATH`, `ERROR_NETNAME_DELETED`,
73    /// `ERROR_UNEXP_NET_ERR`, `ERROR_NETWORK_UNREACHABLE`.
74    DeviceNotReady,
75    /// Windows `ERROR_SHARING_VIOLATION` and `ERROR_LOCK_VIOLATION`.
76    SharingViolation,
77    /// `EBUSY`, `ETXTBSY`, `EAGAIN`, `EDEADLK`; `SQLITE_BUSY`, `SQLITE_LOCKED`, the
78    /// `SQLITE_IOERR_*LOCK` family and `SQLITE_PROTOCOL`.
79    LockedOrBusy,
80    /// The file is not there; there is nothing to set aside.
81    FileMissing,
82    /// Anything else that is not a read failure of this file: a write-side I/O error, `EMFILE`,
83    /// out of memory, a directory where the file should be, or a file this process can read
84    /// but SQLite could not open.
85    Other,
86}
87
88impl fmt::Display for StorageProblem {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        f.write_str(match self {
91            Self::DiskFull => "the disk is full",
92            Self::ReadOnlyFilesystem => "the filesystem is read-only",
93            Self::DeviceNotReady => "the device or share is not ready",
94            Self::SharingViolation => "a sharing or lock violation",
95            Self::LockedOrBusy => "the database is busy or locked",
96            Self::FileMissing => "the file is missing",
97            Self::Other => "the storage reported an unclassified error",
98        })
99    }
100}
101
102impl fmt::Display for UnreadableCause {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        match self {
105            Self::PermissionDenied => f.write_str("permission denied reading the file"),
106            Self::IoReadFailure => f.write_str("I/O error reading the file"),
107            Self::StorageUnavailable(p) => write!(f, "storage unavailable: {p}"),
108        }
109    }
110}
111
112/// What [`DbOptions::open_writer_recovering_unreadable`] did.
113#[derive(Clone, Debug, Eq, PartialEq)]
114#[non_exhaustive]
115pub enum RecoveryAction {
116    /// The database opened; nothing was moved. Any failure counter was reset.
117    OpenedNormally,
118    /// The database was corrupt and the [`RecoveryPolicy`] allowed quarantine.
119    QuarantinedCorrupt,
120    /// The database could not be read for `consecutive_failures` consecutive opens and the
121    /// [`UnreadablePolicy`] allowed quarantine. The old file is preserved at `quarantined`.
122    QuarantinedUnreadable {
123        cause: UnreadableCause,
124        consecutive_failures: u32,
125    },
126}
127
128/// Result of an open that may have set an unreadable or corrupt database aside.
129#[derive(Debug)]
130pub struct UnreadableRecovery {
131    pub conn: Connection,
132    /// Index of the key candidate that opened (always 0 here).
133    pub key_index: usize,
134    /// Where the old file went, when it was set aside.
135    pub quarantined: Option<PathBuf>,
136    pub action: RecoveryAction,
137}
138
139impl From<UnreadableRecovery> for OpenOutcome {
140    fn from(r: UnreadableRecovery) -> Self {
141        OpenOutcome {
142            conn: r.conn,
143            key_index: r.key_index,
144            quarantined: r.quarantined,
145        }
146    }
147}
148
149/// Classify an open-time failure that is neither corruption nor an unrelated error. `None` means
150/// "not in this family": the caller returns the original error. `path` is the database file; it
151/// is probed (opened for reading) only to tell a permission failure from other `SQLITE_CANTOPEN`
152/// causes, because SQLite reports `EACCES` as `SQLITE_CANTOPEN`.
153pub fn classify_open_failure(error: &DbError, path: &Path) -> Option<UnreadableCause> {
154    match error {
155        DbError::Sqlite(rusqlite::Error::SqliteFailure(failure, _)) => {
156            classify_sqlite(failure.code, failure.extended_code, path)
157        }
158        other => {
159            let mut source: Option<&(dyn std::error::Error + 'static)> =
160                std::error::Error::source(other);
161            while let Some(e) = source {
162                if let Some(io) = e.downcast_ref::<std::io::Error>() {
163                    return Some(classify_io(io));
164                }
165                source = e.source();
166            }
167            None
168        }
169    }
170}
171
172// Extended result codes (sqlite3.h); named here so the table does not depend on which
173// constants the bindings export.
174const IOERR_READ: i32 = 266;
175const IOERR_SHORT_READ: i32 = 522;
176const IOERR_UNLOCK: i32 = 2058;
177const IOERR_RDLOCK: i32 = 2314;
178const IOERR_BLOCKED: i32 = 2826;
179const IOERR_CHECKRESERVEDLOCK: i32 = 3594;
180const IOERR_LOCK: i32 = 3850;
181const IOERR_SHMLOCK: i32 = 5130;
182const IOERR_DATA: i32 = 8202;
183const IOERR_CORRUPTFS: i32 = 8458;
184const CANTOPEN_PLAIN: i32 = 14;
185
186fn storage(p: StorageProblem) -> Option<UnreadableCause> {
187    Some(UnreadableCause::StorageUnavailable(p))
188}
189
190fn classify_sqlite(code: ErrorCode, extended: i32, path: &Path) -> Option<UnreadableCause> {
191    match code {
192        ErrorCode::PermissionDenied => Some(UnreadableCause::PermissionDenied),
193        ErrorCode::DiskFull => storage(StorageProblem::DiskFull),
194        ErrorCode::ReadOnly => storage(StorageProblem::ReadOnlyFilesystem),
195        ErrorCode::DatabaseBusy
196        | ErrorCode::DatabaseLocked
197        | ErrorCode::FileLockingProtocolFailed => storage(StorageProblem::LockedOrBusy),
198        ErrorCode::OutOfMemory => storage(StorageProblem::Other),
199        ErrorCode::SystemIoFailure => match extended {
200            IOERR_READ | IOERR_SHORT_READ => Some(UnreadableCause::IoReadFailure),
201            IOERR_UNLOCK
202            | IOERR_RDLOCK
203            | IOERR_BLOCKED
204            | IOERR_CHECKRESERVEDLOCK
205            | IOERR_LOCK
206            | IOERR_SHMLOCK => storage(StorageProblem::LockedOrBusy),
207            // File-system level corruption is not a read failure of the device: leave it to
208            // the corruption policy, which does not claim it either.
209            IOERR_DATA | IOERR_CORRUPTFS => None,
210            _ => storage(StorageProblem::Other),
211        },
212        ErrorCode::CannotOpen if extended == CANTOPEN_PLAIN => match std::fs::File::open(path) {
213            // This process can read the file: SQLite's failure was about writing or locking,
214            // not about reading. Not ours to set aside.
215            Ok(_) => storage(StorageProblem::Other),
216            Err(e) => Some(classify_io(&e)),
217        },
218        ErrorCode::CannotOpen => storage(StorageProblem::Other),
219        _ => None,
220    }
221}
222
223fn classify_io(e: &std::io::Error) -> UnreadableCause {
224    use std::io::ErrorKind as K;
225    match e.kind() {
226        K::PermissionDenied => return UnreadableCause::PermissionDenied,
227        K::NotFound => return UnreadableCause::StorageUnavailable(StorageProblem::FileMissing),
228        K::StorageFull | K::QuotaExceeded => {
229            return UnreadableCause::StorageUnavailable(StorageProblem::DiskFull)
230        }
231        K::ReadOnlyFilesystem => {
232            return UnreadableCause::StorageUnavailable(StorageProblem::ReadOnlyFilesystem)
233        }
234        K::ResourceBusy | K::Deadlock | K::WouldBlock => {
235            return UnreadableCause::StorageUnavailable(StorageProblem::LockedOrBusy)
236        }
237        K::StaleNetworkFileHandle
238        | K::NotConnected
239        | K::NetworkDown
240        | K::NetworkUnreachable
241        | K::HostUnreachable
242        | K::TimedOut => {
243            return UnreadableCause::StorageUnavailable(StorageProblem::DeviceNotReady)
244        }
245        _ => {}
246    }
247    match e.raw_os_error() {
248        Some(code) => classify_os_error(code),
249        None => UnreadableCause::StorageUnavailable(StorageProblem::Other),
250    }
251}
252
253#[cfg(unix)]
254fn classify_os_error(code: i32) -> UnreadableCause {
255    use StorageProblem as P;
256    let problem = match code {
257        libc::EIO => return UnreadableCause::IoReadFailure,
258        libc::ENXIO | libc::ENODEV => P::DeviceNotReady,
259        libc::EBUSY | libc::ETXTBSY | libc::EAGAIN | libc::EDEADLK => P::LockedOrBusy,
260        libc::ENOSPC | libc::EDQUOT => P::DiskFull,
261        libc::EROFS => P::ReadOnlyFilesystem,
262        libc::ESTALE
263        | libc::ENOTCONN
264        | libc::ENETDOWN
265        | libc::ENETUNREACH
266        | libc::EHOSTUNREACH
267        | libc::ETIMEDOUT => P::DeviceNotReady,
268        #[cfg(target_os = "linux")]
269        libc::ENOMEDIUM => P::DeviceNotReady,
270        _ => P::Other,
271    };
272    UnreadableCause::StorageUnavailable(problem)
273}
274
275#[cfg(windows)]
276fn classify_os_error(code: i32) -> UnreadableCause {
277    use StorageProblem as P;
278    let problem = match code {
279        // ERROR_IO_DEVICE, ERROR_CRC, ERROR_SECTOR_NOT_FOUND, ERROR_READ_FAULT
280        1117 | 23 | 27 | 30 => return UnreadableCause::IoReadFailure,
281        // ERROR_SHARING_VIOLATION, ERROR_LOCK_VIOLATION
282        32 | 33 => P::SharingViolation,
283        // ERROR_NOT_READY, ERROR_DEV_NOT_EXIST, ERROR_DEVICE_NOT_CONNECTED, ERROR_BAD_NETPATH,
284        // ERROR_NETNAME_DELETED, ERROR_UNEXP_NET_ERR, ERROR_NETWORK_UNREACHABLE
285        21 | 55 | 1167 | 53 | 64 | 59 | 1231 => P::DeviceNotReady,
286        // ERROR_DISK_FULL, ERROR_HANDLE_DISK_FULL
287        112 | 39 => P::DiskFull,
288        // ERROR_WRITE_PROTECT
289        19 => P::ReadOnlyFilesystem,
290        _ => P::Other,
291    };
292    UnreadableCause::StorageUnavailable(problem)
293}
294
295#[cfg(not(any(unix, windows)))]
296fn classify_os_error(_code: i32) -> UnreadableCause {
297    UnreadableCause::StorageUnavailable(StorageProblem::Other)
298}
299
300fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
301    let mut name = path.as_os_str().to_os_string();
302    name.push(suffix);
303    PathBuf::from(name)
304}
305
306/// Where the consecutive-failure count is kept: beside the database.
307pub fn unreadable_counter_path(db: &Path) -> PathBuf {
308    with_suffix(db, ".unreadable-opens")
309}
310
311fn read_counter(path: &Path) -> u32 {
312    std::fs::read_to_string(path)
313        .ok()
314        .and_then(|s| s.lines().nth(1).and_then(|l| l.trim().parse().ok()))
315        .unwrap_or(0)
316}
317
318fn bump_counter(path: &Path) -> Result<u32> {
319    let next = read_counter(path).saturating_add(1);
320    rightkit_fs::atomic_write_private(
321        path,
322        format!("rightkit-db-unreadable.v1\n{next}\n").as_bytes(),
323    )?;
324    Ok(next)
325}
326
327fn reset_counter(path: &Path) {
328    // Best effort and only when present: the common open touches nothing.
329    if std::fs::symlink_metadata(path).is_ok() {
330        let _ = std::fs::remove_file(path);
331    }
332}
333
334fn is_regular_file(path: &Path) -> bool {
335    std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_file())
336}
337
338impl DbOptions {
339    /// [`DbOptions::open_writer_recovering`] plus a policy for a database that cannot be read.
340    ///
341    /// - Opens normally: returns [`RecoveryAction::OpenedNormally`] and clears the failure count.
342    /// - Corrupt (not-a-database, corrupt, failed integrity check): as `open_writer_recovering`,
343    ///   decided by `policy`; quarantined gives [`RecoveryAction::QuarantinedCorrupt`].
344    /// - Unreadable or storage unavailable ([`UnreadableCause`]): decided by `unreadable`; the
345    ///   default [`UnreadablePolicy::Fail`] returns [`DbError::Unreadable`] and touches nothing.
346    /// - Any other error is returned unchanged.
347    ///
348    /// # Errors
349    /// [`DbError::Unreadable`] as above; the original error otherwise; a quarantine or reopen
350    /// failure after the file was set aside (the old file stays preserved).
351    pub fn open_writer_recovering_unreadable(
352        &self,
353        policy: RecoveryPolicy,
354        unreadable: UnreadablePolicy,
355    ) -> Result<UnreadableRecovery> {
356        self.open_writer_recovering_via(policy, unreadable, || self.open_writer())
357    }
358
359    /// As [`DbOptions::open_writer_recovering_unreadable`], with the open step supplied by the
360    /// caller: it is called once for the first attempt and once more after a quarantine. This is
361    /// the seam for tests that inject open errors and for apps with their own bootstrap.
362    pub fn open_writer_recovering_via(
363        &self,
364        policy: RecoveryPolicy,
365        unreadable: UnreadablePolicy,
366        mut open: impl FnMut() -> Result<Connection>,
367    ) -> Result<UnreadableRecovery> {
368        let counter = unreadable_counter_path(&self.path);
369        let error = match open() {
370            Ok(conn) => {
371                reset_counter(&counter);
372                return Ok(UnreadableRecovery {
373                    conn,
374                    key_index: 0,
375                    quarantined: None,
376                    action: RecoveryAction::OpenedNormally,
377                });
378            }
379            Err(error) => error,
380        };
381        if is_unreadable(&error) && allowed(self, policy) {
382            let moved =
383                quarantine_database_with(&self.path, policy.quarantine_stamp, policy.sidecars)?;
384            let conn = open()?;
385            reset_counter(&counter);
386            return Ok(UnreadableRecovery {
387                conn,
388                key_index: 0,
389                quarantined: Some(moved),
390                action: RecoveryAction::QuarantinedCorrupt,
391            });
392        }
393        let Some(cause) = classify_open_failure(&error, &self.path) else {
394            return Err(error);
395        };
396        let threshold = match unreadable {
397            UnreadablePolicy::QuarantineAfter { attempts } if cause.counts_toward_quarantine() => {
398                Some(attempts.max(1))
399            }
400            _ => None,
401        };
402        let Some(threshold) = threshold else {
403            // Fail, or a storage problem: never count, never move.
404            return Err(self.unreadable_error(cause, read_counter(&counter), None, error));
405        };
406        let failures = match bump_counter(&counter) {
407            Ok(n) => n,
408            // Cannot record the count, so cannot honour "consecutive": stay safe and fail.
409            Err(_) => {
410                return Err(self.unreadable_error(
411                    cause,
412                    read_counter(&counter),
413                    Some(threshold),
414                    error,
415                ))
416            }
417        };
418        if failures < threshold || !is_regular_file(&self.path) {
419            return Err(self.unreadable_error(cause, failures, Some(threshold), error));
420        }
421        // Rename only, and sidecars always travel with the file: nothing is deleted.
422        let moved = quarantine_database_with(
423            &self.path,
424            policy.quarantine_stamp,
425            SidecarPolicy::RenameSidecars,
426        )?;
427        reset_counter(&counter);
428        let conn = open()?;
429        Ok(UnreadableRecovery {
430            conn,
431            key_index: 0,
432            quarantined: Some(moved),
433            action: RecoveryAction::QuarantinedUnreadable {
434                cause,
435                consecutive_failures: failures,
436            },
437        })
438    }
439
440    fn unreadable_error(
441        &self,
442        cause: UnreadableCause,
443        consecutive_failures: u32,
444        quarantine_after: Option<u32>,
445        source: DbError,
446    ) -> DbError {
447        DbError::Unreadable {
448            path: self.path.clone(),
449            cause,
450            consecutive_failures,
451            quarantine_after,
452            source: Box::new(source),
453        }
454    }
455}