1use crate::store::{configure_memory, normalize_path};
14use crate::{
15 SqliteSyncError, SqliteSyncResult, SqliteSyncStore, SQLITE_SYNC_SCHEMA_V1,
16 SQLITE_SYNC_SCHEMA_V2, SQLITE_SYNC_SCHEMA_V3, SQLITE_SYNC_SCHEMA_V4, SQLITE_SYNC_SCHEMA_V5,
17};
18use rusqlite::backup::Backup;
19use rusqlite::{Connection, OpenFlags, TransactionBehavior};
20use sha2::{Digest, Sha256};
21use std::fs::{self, File, OpenOptions};
22use std::io::Read;
23use std::path::{Path, PathBuf};
24use std::time::Duration;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct SqliteBackupReport {
29 pub schema_version: u32,
31 pub bytes: u64,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct SqlitePairedBackupReport {
38 pub local: SqliteBackupReport,
40 pub queue: SqliteBackupReport,
42 pub queue_entries: u64,
44 pub total_bytes: u64,
46}
47
48impl SqliteSyncStore {
49 pub fn online_backup(
51 &self,
52 destination: impl AsRef<Path>,
53 ) -> SqliteSyncResult<SqliteBackupReport> {
54 if self.config().separate_outbox_path().is_some() {
55 return Err(SqliteSyncError::InvalidConfiguration(
56 "separate outbox requires a paired backup",
57 ));
58 }
59 let destination = normalize_backup_destination(destination.as_ref())?;
60 let temporary = reserve_temporary(&destination)?;
61 let result = self.copy_backup(&temporary).and_then(|report| {
62 sync_file(&temporary)?;
63 publish_temporary(&temporary, &destination)?;
64 Ok(report)
65 });
66 if result.is_err() {
67 let _ = fs::remove_file(&temporary);
68 }
69 result
70 }
71
72 pub fn paired_online_backup(
75 &self,
76 destination: impl AsRef<Path>,
77 ) -> SqliteSyncResult<SqlitePairedBackupReport> {
78 if self.config().separate_outbox_path().is_none() {
79 return Err(SqliteSyncError::InvalidConfiguration(
80 "paired backup requires a separate outbox",
81 ));
82 }
83 let destination = normalize_backup_destination(destination.as_ref())?;
84 let temporary = reserve_temporary_directory(&destination)?;
85 let local_name = safe_manifest_name(
86 self.config()
87 .path
88 .file_name()
89 .and_then(|name| name.to_str()),
90 )?;
91 let queue_name = safe_manifest_name(
92 self.config()
93 .separate_outbox_path()
94 .and_then(Path::file_name)
95 .and_then(|name| name.to_str()),
96 )?;
97 let result = self.with_connection(|connection| {
98 let transaction = connection
99 .transaction_with_behavior(TransactionBehavior::Exclusive)
100 .map_err(SqliteSyncError::database)?;
101 let _: i64 = transaction
103 .query_row("SELECT count(*) FROM main.appcore_object_head", [], |row| row.get(0))
104 .map_err(SqliteSyncError::database)?;
105 let queue_entries: u64 = transaction
106 .query_row("SELECT count(*) FROM sync_queue.appcore_sync_outbox", [], |row| row.get(0))
107 .map_err(SqliteSyncError::database)?;
108 let local_path = temporary.join("local.sqlite3");
109 let queue_path = temporary.join("sync-queue.sqlite3");
110 fs::copy(self.config().path(), &local_path)
111 .map_err(|_| SqliteSyncError::DatabaseOperation)?;
112 fs::copy(
113 self.config().separate_outbox_path().ok_or(
114 SqliteSyncError::InvalidConfiguration("separate outbox required"),
115 )?,
116 &queue_path,
117 )
118 .map_err(|_| SqliteSyncError::DatabaseOperation)?;
119 let local = validate_pair_database(&local_path, false)?;
120 let queue = validate_pair_database(&queue_path, true)?;
121 drop(transaction);
122 let manifest = format!(
123 "appcore-sync-pair-v1\nlocal_file={local_name}\nqueue_file={queue_name}\nlocal_schema={}\nlocal_bytes={}\nlocal_sha256={}\nqueue_schema={}\nqueue_bytes={}\nqueue_sha256={}\nqueue_entries={}\n",
124 local.schema_version, local.bytes, file_sha256(&local_path)?, queue.schema_version, queue.bytes, file_sha256(&queue_path)?, queue_entries
125 );
126 let manifest_path = temporary.join("manifest.txt");
127 fs::write(&manifest_path, manifest).map_err(|_| SqliteSyncError::DatabaseOperation)?;
128 sync_file(&local_path)?;
129 sync_file(&queue_path)?;
130 sync_file(&manifest_path)?;
131 sync_parent(&local_path)?;
132 let total_bytes = local.bytes.saturating_add(queue.bytes);
133 Ok(SqlitePairedBackupReport { local, queue, queue_entries, total_bytes })
134 });
135 let result = result.and_then(|report| {
136 publish_directory(&temporary, &destination)?;
137 Ok(report)
138 });
139 if result.is_err() {
140 let _ = fs::remove_dir_all(&temporary);
141 }
142 result
143 }
144
145 pub fn restore_paired_backup_to_new(
147 bundle: impl AsRef<Path>,
148 destination_directory: impl AsRef<Path>,
149 ) -> SqliteSyncResult<SqlitePairedBackupReport> {
150 let bundle = normalize_bundle_directory(bundle.as_ref())?;
151 let report = validate_paired_bundle(&bundle)?;
152 let destination = normalize_backup_destination(destination_directory.as_ref())?;
153 let temporary = reserve_temporary_directory(&destination)?;
154 let result = (|| {
155 let manifest = fs::read_to_string(bundle.join("manifest.txt"))
156 .map_err(|_| SqliteSyncError::IntegrityFailed)?;
157 let (local_name, queue_name) = manifest_names(&manifest)?;
158 for (source, name) in [
159 ("local.sqlite3", local_name),
160 ("sync-queue.sqlite3", queue_name),
161 ] {
162 fs::copy(bundle.join(source), temporary.join(&name))
163 .map_err(|_| SqliteSyncError::DatabaseOperation)?;
164 sync_file(&temporary.join(name))?;
165 }
166 fs::copy(bundle.join("manifest.txt"), temporary.join("manifest.txt"))
167 .map_err(|_| SqliteSyncError::DatabaseOperation)?;
168 sync_file(&temporary.join("manifest.txt"))?;
169 validate_paired_bundle(&temporary)?;
170 sync_parent(&temporary.join("local.sqlite3"))?;
171 publish_directory(&temporary, &destination)?;
172 Ok(report)
173 })();
174 if result.is_err() {
175 let _ = fs::remove_dir_all(&temporary);
176 }
177 result
178 }
179
180 fn copy_backup(&self, temporary: &Path) -> SqliteSyncResult<SqliteBackupReport> {
181 self.with_connection(|source| {
182 copy_connection(source, temporary, self.config().backup_pages_per_step)
183 })
184 }
185
186 pub fn restore_backup_to_new(
188 backup_path: impl AsRef<Path>,
189 destination: impl AsRef<Path>,
190 ) -> SqliteSyncResult<SqliteBackupReport> {
191 let original_backup_path = backup_path.as_ref();
192 let backup_path = normalize_path(original_backup_path)?;
193 if !backup_path.is_file() {
194 return Err(SqliteSyncError::UnsafePath);
195 }
196 let destination = normalize_backup_destination(destination.as_ref())?;
197 let source = Connection::open_with_flags(
198 &backup_path,
199 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_FULL_MUTEX,
200 )
201 .map_err(SqliteSyncError::database)?;
202 configure_memory(&source)?;
203 validate_backup_connection(&source)?;
204 let temporary = reserve_temporary(&destination)?;
205 let result = copy_connection(&source, &temporary, 128).and_then(|report| {
206 sync_file(&temporary)?;
207 publish_temporary(&temporary, &destination)?;
208 Ok(report)
209 });
210 if result.is_err() {
211 let _ = fs::remove_file(&temporary);
212 }
213 result
214 }
215}
216
217fn copy_connection(
218 source: &Connection,
219 temporary: &Path,
220 pages_per_step: i32,
221) -> SqliteSyncResult<SqliteBackupReport> {
222 let flags = OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_FULL_MUTEX;
223 let mut destination =
224 Connection::open_with_flags(temporary, flags).map_err(SqliteSyncError::database)?;
225 configure_memory(&destination)?;
226 let backup = Backup::new(source, &mut destination).map_err(SqliteSyncError::database)?;
227 backup
228 .run_to_completion(pages_per_step, Duration::from_millis(2), None)
229 .map_err(SqliteSyncError::database)?;
230 drop(backup);
231 let schema_version = validate_backup_connection(&destination)?;
232 drop(destination);
233 let bytes = fs::metadata(temporary)
234 .map_err(|_| SqliteSyncError::DatabaseOperation)?
235 .len();
236 Ok(SqliteBackupReport {
237 schema_version,
238 bytes,
239 })
240}
241
242fn reserve_temporary_directory(destination: &Path) -> SqliteSyncResult<PathBuf> {
243 let parent = destination.parent().unwrap_or_else(|| Path::new("."));
244 let name = destination
245 .file_name()
246 .and_then(|value| value.to_str())
247 .ok_or(SqliteSyncError::UnsafePath)?;
248 for attempt in 0..16u8 {
249 let candidate = parent.join(format!(".{name}.{}-{attempt}.tmp", std::process::id()));
250 match fs::create_dir(&candidate) {
251 Ok(()) => return Ok(candidate),
252 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
253 Err(_) => return Err(SqliteSyncError::DatabaseOperation),
254 }
255 }
256 Err(SqliteSyncError::CapacityExceeded("backup temporary"))
257}
258
259fn publish_directory(temporary: &Path, destination: &Path) -> SqliteSyncResult<()> {
260 fs::rename(temporary, destination).map_err(|_| SqliteSyncError::DatabaseOperation)?;
261 if sync_parent(destination).is_err() {
262 let _ = fs::rename(destination, temporary);
263 return Err(SqliteSyncError::DatabaseOperation);
264 }
265 Ok(())
266}
267
268fn validate_paired_bundle(bundle: &Path) -> SqliteSyncResult<SqlitePairedBackupReport> {
269 let manifest_path = bundle.join("manifest.txt");
270 let manifest =
271 fs::read_to_string(&manifest_path).map_err(|_| SqliteSyncError::IntegrityFailed)?;
272 let (local_name, queue_name) = manifest_names(&manifest)?;
273 let local_path = if bundle.join("local.sqlite3").is_file() {
274 bundle.join("local.sqlite3")
275 } else {
276 bundle.join(&local_name)
277 };
278 let queue_path = if bundle.join("sync-queue.sqlite3").is_file() {
279 bundle.join("sync-queue.sqlite3")
280 } else {
281 bundle.join(&queue_name)
282 };
283 let local = validate_pair_database(&local_path, false)?;
284 let queue = validate_pair_database(&queue_path, true)?;
285 let local_connection = Connection::open_with_flags(
286 &local_path,
287 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_FULL_MUTEX,
288 )
289 .map_err(SqliteSyncError::database)?;
290 let recorded_queue_name: String = local_connection
291 .query_row(
292 "SELECT outbox_file FROM appcore_sync_storage_mode WHERE singleton=1",
293 [],
294 |row| row.get(0),
295 )
296 .map_err(SqliteSyncError::database)?;
297 if recorded_queue_name != queue_name {
298 return Err(SqliteSyncError::IntegrityFailed);
299 }
300 let connection = Connection::open_with_flags(
301 &queue_path,
302 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_FULL_MUTEX,
303 )
304 .map_err(SqliteSyncError::database)?;
305 let queue_entries: u64 = connection
306 .query_row("SELECT count(*) FROM appcore_sync_outbox", [], |row| {
307 row.get(0)
308 })
309 .map_err(SqliteSyncError::database)?;
310 let expected = format!(
311 "appcore-sync-pair-v1\nlocal_file={local_name}\nqueue_file={queue_name}\nlocal_schema={}\nlocal_bytes={}\nlocal_sha256={}\nqueue_schema={}\nqueue_bytes={}\nqueue_sha256={}\nqueue_entries={}\n",
312 local.schema_version, local.bytes, file_sha256(&local_path)?, queue.schema_version, queue.bytes, file_sha256(&queue_path)?, queue_entries
313 );
314 if manifest != expected {
315 return Err(SqliteSyncError::IntegrityFailed);
316 }
317 Ok(SqlitePairedBackupReport {
318 total_bytes: local.bytes.saturating_add(queue.bytes),
319 local,
320 queue,
321 queue_entries,
322 })
323}
324
325fn manifest_names(manifest: &str) -> SqliteSyncResult<(String, String)> {
326 let mut lines = manifest.lines();
327 if lines.next() != Some("appcore-sync-pair-v1") {
328 return Err(SqliteSyncError::UpdateRequired);
329 }
330 let mut local = None;
331 let mut queue = None;
332 for line in lines {
333 if let Some(value) = line.strip_prefix("local_file=") {
334 local = Some(safe_manifest_name(Some(value))?);
335 }
336 if let Some(value) = line.strip_prefix("queue_file=") {
337 queue = Some(safe_manifest_name(Some(value))?);
338 }
339 }
340 Ok((
341 local.ok_or(SqliteSyncError::IntegrityFailed)?,
342 queue.ok_or(SqliteSyncError::IntegrityFailed)?,
343 ))
344}
345
346fn safe_manifest_name(value: Option<&str>) -> SqliteSyncResult<String> {
347 let value = value.ok_or(SqliteSyncError::UnsafePath)?;
348 if value.is_empty()
349 || value == "."
350 || value == ".."
351 || value.contains('/')
352 || value.contains('\\')
353 || value.bytes().any(|byte| byte.is_ascii_control())
354 {
355 return Err(SqliteSyncError::UnsafePath);
356 }
357 Ok(value.to_owned())
358}
359
360fn validate_pair_database(path: &Path, queue: bool) -> SqliteSyncResult<SqliteBackupReport> {
361 if !path.is_file() {
362 return Err(SqliteSyncError::UnsafePath);
363 }
364 let connection = Connection::open_with_flags(
365 path,
366 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_FULL_MUTEX,
367 )
368 .map_err(SqliteSyncError::database)?;
369 let schema_version: u32 = connection
370 .pragma_query_value(None, "user_version", |row| row.get(0))
371 .map_err(SqliteSyncError::database)?;
372 let application_id: i64 = connection
373 .pragma_query_value(None, "application_id", |row| row.get(0))
374 .map_err(SqliteSyncError::database)?;
375 let expected_id = if queue { 0x4150_4351 } else { 0x4150_4353 };
376 if application_id != expected_id {
377 return Err(SqliteSyncError::UpdateRequired);
378 }
379 if queue {
380 let integrity: String = connection
381 .query_row("PRAGMA quick_check(1)", [], |row| row.get(0))
382 .map_err(SqliteSyncError::database)?;
383 if integrity != "ok" {
384 return Err(SqliteSyncError::IntegrityFailed);
385 }
386 } else if validate_backup_connection(&connection).is_err()
387 && !validate_local_backup_connection(&connection)?
388 {
389 return Err(SqliteSyncError::IntegrityFailed);
390 }
391 if queue {
392 let has_queue: bool = connection
393 .query_row("SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name='appcore_sync_outbox')", [], |row| row.get(0))
394 .map_err(SqliteSyncError::database)?;
395 if !has_queue || schema_version != 1 {
396 return Err(SqliteSyncError::UpdateRequired);
397 }
398 }
399 let bytes = fs::metadata(path)
400 .map_err(|_| SqliteSyncError::DatabaseOperation)?
401 .len();
402 Ok(SqliteBackupReport {
403 schema_version,
404 bytes,
405 })
406}
407
408fn validate_local_backup_connection(connection: &Connection) -> SqliteSyncResult<bool> {
409 let version: u32 = connection
410 .pragma_query_value(None, "user_version", |row| row.get(0))
411 .map_err(SqliteSyncError::database)?;
412 if !(SQLITE_SYNC_SCHEMA_V1..=SQLITE_SYNC_SCHEMA_V5).contains(&version) {
413 return Ok(false);
414 }
415 let integrity: String = connection
416 .query_row("PRAGMA quick_check(1)", [], |row| row.get(0))
417 .map_err(SqliteSyncError::database)?;
418 let mode: String = connection
419 .query_row(
420 "SELECT outbox_mode FROM appcore_sync_storage_mode WHERE singleton=1",
421 [],
422 |row| row.get(0),
423 )
424 .map_err(SqliteSyncError::database)?;
425 Ok(integrity == "ok" && mode == "separate")
426}
427
428fn file_sha256(path: &Path) -> SqliteSyncResult<String> {
429 let mut file = File::open(path).map_err(|_| SqliteSyncError::DatabaseOperation)?;
430 let mut hasher = Sha256::new();
431 let mut buffer = [0u8; 64 * 1024];
432 loop {
433 let read = file
434 .read(&mut buffer)
435 .map_err(|_| SqliteSyncError::DatabaseOperation)?;
436 if read == 0 {
437 break;
438 }
439 hasher.update(&buffer[..read]);
440 }
441 Ok(hasher
442 .finalize()
443 .iter()
444 .map(|byte| format!("{byte:02x}"))
445 .collect())
446}
447
448fn normalize_backup_destination(path: &Path) -> SqliteSyncResult<PathBuf> {
449 if path.as_os_str().is_empty() {
450 return Err(SqliteSyncError::UnsafePath);
451 }
452 let normalized = normalize_path(path)?;
453 if normalized.exists() {
454 return Err(SqliteSyncError::UnsafePath);
455 }
456 Ok(normalized)
457}
458
459fn normalize_bundle_directory(path: &Path) -> SqliteSyncResult<PathBuf> {
460 let metadata = fs::symlink_metadata(path).map_err(|_| SqliteSyncError::UnsafePath)?;
461 if !metadata.is_dir() || metadata.file_type().is_symlink() {
462 return Err(SqliteSyncError::UnsafePath);
463 }
464 fs::canonicalize(path).map_err(|_| SqliteSyncError::UnsafePath)
465}
466
467fn reserve_temporary(destination: &Path) -> SqliteSyncResult<PathBuf> {
468 let parent = destination.parent().unwrap_or_else(|| Path::new("."));
469 let name = destination
470 .file_name()
471 .and_then(|value| value.to_str())
472 .ok_or(SqliteSyncError::UnsafePath)?;
473 for attempt in 0..16u8 {
474 let candidate = parent.join(format!(".{name}.{}-{attempt}.tmp", std::process::id()));
475 match OpenOptions::new()
476 .write(true)
477 .create_new(true)
478 .open(&candidate)
479 {
480 Ok(file) => {
481 drop(file);
482 return Ok(candidate);
483 }
484 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
485 Err(_) => return Err(SqliteSyncError::DatabaseOperation),
486 }
487 }
488 Err(SqliteSyncError::CapacityExceeded("backup temporary"))
489}
490
491fn publish_temporary(temporary: &Path, destination: &Path) -> SqliteSyncResult<()> {
492 fs::hard_link(temporary, destination).map_err(|_| SqliteSyncError::DatabaseOperation)?;
493 if fs::remove_file(temporary).is_err() {
494 let _ = fs::remove_file(destination);
495 return Err(SqliteSyncError::DatabaseOperation);
496 }
497 if sync_parent(destination).is_err() {
498 let _ = fs::remove_file(destination);
499 let _ = sync_parent(destination);
500 return Err(SqliteSyncError::DatabaseOperation);
501 }
502 Ok(())
503}
504
505fn validate_backup_connection(connection: &Connection) -> SqliteSyncResult<u32> {
506 let version: u32 = connection
507 .pragma_query_value(None, "user_version", |row| row.get(0))
508 .map_err(SqliteSyncError::database)?;
509 if version >= SQLITE_SYNC_SCHEMA_V5 {
510 let mode: String = connection
511 .query_row(
512 "SELECT outbox_mode FROM appcore_sync_storage_mode WHERE singleton=1",
513 [],
514 |row| row.get(0),
515 )
516 .map_err(SqliteSyncError::database)?;
517 if mode != "local" {
518 return Err(SqliteSyncError::UpdateRequired);
519 }
520 }
521 let application_id: i64 = connection
522 .pragma_query_value(None, "application_id", |row| row.get(0))
523 .map_err(SqliteSyncError::database)?;
524 if application_id != 0x4150_4353 {
525 return Err(SqliteSyncError::UpdateRequired);
526 }
527 let integrity: String = connection
528 .query_row("PRAGMA quick_check(1)", [], |row| row.get(0))
529 .map_err(SqliteSyncError::database)?;
530 if matches!(
531 version,
532 SQLITE_SYNC_SCHEMA_V1
533 | SQLITE_SYNC_SCHEMA_V2
534 | SQLITE_SYNC_SCHEMA_V3
535 | SQLITE_SYNC_SCHEMA_V4
536 | SQLITE_SYNC_SCHEMA_V5
537 ) && integrity == "ok"
538 {
539 Ok(version)
540 } else if !matches!(
541 version,
542 SQLITE_SYNC_SCHEMA_V1
543 | SQLITE_SYNC_SCHEMA_V2
544 | SQLITE_SYNC_SCHEMA_V3
545 | SQLITE_SYNC_SCHEMA_V4
546 | SQLITE_SYNC_SCHEMA_V5
547 ) {
548 Err(SqliteSyncError::UpdateRequired)
549 } else {
550 Err(SqliteSyncError::IntegrityFailed)
551 }
552}
553
554fn sync_file(path: &Path) -> SqliteSyncResult<()> {
555 let mut options = OpenOptions::new();
556 options.read(true);
557 #[cfg(windows)]
558 options.write(true);
559 options
560 .open(path)
561 .and_then(|file| file.sync_all())
562 .map_err(|_| SqliteSyncError::DatabaseOperation)
563}
564
565#[cfg(unix)]
566fn sync_parent(path: &Path) -> SqliteSyncResult<()> {
567 let parent = path.parent().unwrap_or_else(|| Path::new("."));
568 File::open(parent)
569 .and_then(|directory| directory.sync_all())
570 .map_err(|_| SqliteSyncError::DatabaseOperation)
571}
572
573#[cfg(windows)]
574fn sync_parent(path: &Path) -> SqliteSyncResult<()> {
575 use std::os::windows::fs::OpenOptionsExt;
576 let parent = path.parent().ok_or(SqliteSyncError::UnsafePath)?;
577 OpenOptions::new()
578 .read(true)
579 .write(true)
580 .custom_flags(windows_sys::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS)
581 .open(parent)
582 .and_then(|directory| directory.sync_all())
583 .map_err(|_| SqliteSyncError::DatabaseOperation)
584}
585#[cfg(not(any(unix, windows)))]
586fn sync_parent(_path: &Path) -> SqliteSyncResult<()> {
587 Err(SqliteSyncError::DatabaseOperation)
588}