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),
43}
44
45impl PartialEq for QuarantineStamp {
49 fn eq(&self, other: &Self) -> bool {
50 match (self, other) {
51 (Self::Millis, Self::Millis) | (Self::Seconds, Self::Seconds) => true,
52 (Self::Custom(a), Self::Custom(b)) => std::ptr::fn_addr_eq(*a, *b),
53 _ => false,
54 }
55 }
56}
57
58impl Eq for QuarantineStamp {}
59
60impl QuarantineStamp {
61 fn suffix(self, now: SystemTime) -> String {
62 let since_epoch = now.duration_since(UNIX_EPOCH).unwrap_or_default();
63 match self {
64 Self::Millis => format!(".corrupt-{}", since_epoch.as_millis()),
65 Self::Seconds => format!(".corrupt-{}", since_epoch.as_secs()),
66 Self::Custom(format) => format(now),
67 }
68 }
69}
70
71#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
73pub enum SidecarPolicy {
74 #[default]
77 RenameSidecars,
78 DeleteSidecars,
81}
82
83#[derive(Debug)]
84pub struct OpenOutcome {
85 pub conn: Connection,
86 pub key_index: usize,
88 pub quarantined: Option<PathBuf>,
90}
91
92impl DbOptions {
93 pub fn open_writer_with_key_candidates(
95 &self,
96 candidates: &[Zeroizing<String>],
97 ) -> Result<(Connection, usize)> {
98 let mut first_error = None;
99 for (index, candidate) in candidates.iter().enumerate() {
100 let mut attempt = self.clone();
101 attempt.key = Some(candidate.clone());
102 attempt.raw_key = None;
103 match attempt.open_writer() {
104 Ok(conn) => return Ok((conn, index)),
105 Err(error) => {
106 first_error.get_or_insert(error);
107 }
108 }
109 }
110 Err(first_error.unwrap_or(DbError::NoKeyOpened))
111 }
112
113 pub fn open_writer_recovering(&self, policy: RecoveryPolicy) -> Result<OpenOutcome> {
116 match self.open_writer() {
117 Ok(conn) => Ok(OpenOutcome {
118 conn,
119 key_index: 0,
120 quarantined: None,
121 }),
122 Err(error) if is_unreadable(&error) && allowed(self, policy) => {
123 let moved =
124 quarantine_database_with(&self.path, policy.quarantine_stamp, policy.sidecars)?;
125 let conn = self.open_writer()?;
126 Ok(OpenOutcome {
127 conn,
128 key_index: 0,
129 quarantined: Some(moved),
130 })
131 }
132 Err(error) => Err(error),
133 }
134 }
135}
136
137pub(crate) fn allowed(options: &DbOptions, policy: RecoveryPolicy) -> bool {
138 if options.is_keyed() {
139 policy.quarantine_keyed
140 } else {
141 policy.quarantine_corrupt
142 }
143}
144
145pub(crate) fn is_unreadable(error: &DbError) -> bool {
150 match error {
151 DbError::Integrity(_) => true,
152 DbError::Sqlite(rusqlite::Error::SqliteFailure(failure, _)) => matches!(
153 failure.code,
154 ErrorCode::NotADatabase | ErrorCode::DatabaseCorrupt
155 ),
156 _ => false,
157 }
158}
159
160pub fn quarantine_database(db: &Path) -> Result<PathBuf> {
163 quarantine_database_with(db, QuarantineStamp::Millis, SidecarPolicy::RenameSidecars)
164}
165
166pub fn quarantine_database_with(
170 db: &Path,
171 stamp: QuarantineStamp,
172 sidecars: SidecarPolicy,
173) -> Result<PathBuf> {
174 quarantine_database_opts(
175 db,
176 &QuarantineOptions {
177 name: stamp,
178 sidecars,
179 ..QuarantineOptions::default()
180 },
181 )
182}
183
184#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
186pub enum MoveMode {
187 #[default]
189 Rename,
190 HardLinkThenRemove,
194}
195
196#[derive(Clone, Debug, Default)]
198pub struct QuarantineOptions {
199 pub name: QuarantineStamp,
201 pub sidecars: SidecarPolicy,
203 pub move_mode: MoveMode,
205 pub marker: Option<PathBuf>,
209}
210
211const QUARANTINE_NAME_ATTEMPTS: u32 = 100;
214
215pub fn quarantine_database_opts(db: &Path, options: &QuarantineOptions) -> Result<PathBuf> {
223 let name = db
224 .file_name()
225 .ok_or(DbError::InvalidDatabasePath)?
226 .to_string_lossy()
227 .into_owned();
228 let suffix = options.name.suffix(SystemTime::now());
229 if suffix.is_empty() || suffix.contains(['/', '\\', '\0']) {
230 return Err(DbError::InvalidQuarantineSuffix(suffix));
231 }
232 let attempts = match options.name {
233 QuarantineStamp::Seconds => QUARANTINE_NAME_ATTEMPTS,
234 _ => 1,
235 };
236 let mut last = None;
237 for attempt in 0..attempts {
238 let dest = quarantine_candidate(db, &name, &suffix, attempt);
239 if quarantine_name_taken(&dest, options.sidecars) {
240 last = Some(dest);
241 continue;
242 }
243 if let Some(marker) = &options.marker {
244 write_marker(marker, db, &dest)?;
245 }
246 match move_file(db, &dest, options.move_mode) {
247 Ok(()) => {}
248 Err(error)
250 if error.kind() == std::io::ErrorKind::AlreadyExists && attempt + 1 < attempts =>
251 {
252 last = Some(dest);
253 continue;
254 }
255 Err(error) => return Err(DbError::Fs(rightkit_fs::FsError::io(db, error))),
256 }
257 for sidecar in ["-wal", "-shm", "-journal"] {
258 let from = with_suffix(db, sidecar);
259 match options.sidecars {
260 SidecarPolicy::RenameSidecars => {
261 move_if_present(&from, &with_suffix(&dest, sidecar), options.move_mode)?
262 }
263 SidecarPolicy::DeleteSidecars => remove_if_present(&from)?,
264 }
265 }
266 return Ok(dest);
267 }
268 let dest = last.unwrap_or_else(|| quarantine_candidate(db, &name, &suffix, 0));
269 Err(DbError::Fs(rightkit_fs::FsError::io(
270 &dest,
271 std::io::Error::new(
272 std::io::ErrorKind::AlreadyExists,
273 "quarantine destination already exists",
274 ),
275 )))
276}
277
278fn quarantine_candidate(db: &Path, name: &str, suffix: &str, attempt: u32) -> PathBuf {
280 if attempt == 0 {
281 db.with_file_name(format!("{name}{suffix}"))
282 } else {
283 db.with_file_name(format!("{name}{suffix}-{attempt}"))
284 }
285}
286
287fn quarantine_name_taken(dest: &Path, sidecars: SidecarPolicy) -> bool {
290 fn exists(path: &Path) -> bool {
291 std::fs::symlink_metadata(path).is_ok()
292 }
293 exists(dest)
294 || (sidecars == SidecarPolicy::RenameSidecars
295 && ["-wal", "-shm", "-journal"]
296 .iter()
297 .any(|sidecar| exists(&with_suffix(dest, sidecar))))
298}
299
300fn move_file(from: &Path, to: &Path, mode: MoveMode) -> std::io::Result<()> {
303 match mode {
304 MoveMode::Rename => rightkit_fs::rename_exclusive(from, to).map_err(std::io::Error::from),
305 MoveMode::HardLinkThenRemove => {
306 std::fs::hard_link(from, to)?;
307 std::fs::remove_file(from)
308 }
309 }
310}
311
312fn move_if_present(from: &Path, to: &Path, mode: MoveMode) -> Result<()> {
313 match move_file(from, to, mode) {
314 Ok(()) => Ok(()),
315 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
316 Err(error) => Err(DbError::Fs(rightkit_fs::FsError::io(from, error))),
317 }
318}
319
320fn write_marker(marker: &Path, original: &Path, quarantined: &Path) -> Result<()> {
321 let unix_ms = SystemTime::now()
322 .duration_since(UNIX_EPOCH)
323 .unwrap_or_default()
324 .as_millis();
325 let body = format!(
326 "{{\"schema\":\"rightkit-db-quarantine.v1\",\"original\":{},\"quarantined\":{},\"unixMs\":{}}}\n",
327 json_string(&original.to_string_lossy()),
328 json_string(&quarantined.to_string_lossy()),
329 unix_ms
330 );
331 rightkit_fs::atomic_write_private(marker, body.as_bytes())?;
332 Ok(())
333}
334
335fn json_string(value: &str) -> String {
337 let mut out = String::with_capacity(value.len() + 2);
338 out.push('"');
339 for c in value.chars() {
340 match c {
341 '"' => out.push_str("\\\""),
342 '\\' => out.push_str("\\\\"),
343 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
344 c => out.push(c),
345 }
346 }
347 out.push('"');
348 out
349}
350
351fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
352 let mut name = path.as_os_str().to_os_string();
353 name.push(suffix);
354 PathBuf::from(name)
355}
356
357fn remove_if_present(path: &Path) -> Result<()> {
358 match std::fs::remove_file(path) {
359 Ok(()) => Ok(()),
360 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
361 Err(error) => Err(DbError::Fs(rightkit_fs::FsError::io(path, error))),
362 }
363}
364
365#[cfg(feature = "cipher")]
370pub fn rekey(conn: &Connection, new_key: &str) -> Result<()> {
371 crate::validate_key(Some(new_key))?;
372 let statement = Zeroizing::new(format!("PRAGMA rekey = '{new_key}';"));
373 conn.execute_batch(statement.as_str())?;
374 crate::verify_key(conn)
375}