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 let mut builder = fs::DirBuilder::new();
251 #[cfg(unix)]
252 {
253 use std::os::unix::fs::DirBuilderExt;
254 builder.mode(0o700);
255 }
256 match builder.create(&candidate) {
257 Ok(()) => return Ok(candidate),
258 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
259 Err(_) => return Err(SqliteSyncError::DatabaseOperation),
260 }
261 }
262 Err(SqliteSyncError::CapacityExceeded("backup temporary"))
263}
264
265fn publish_directory(temporary: &Path, destination: &Path) -> SqliteSyncResult<()> {
266 fs::rename(temporary, destination).map_err(|_| SqliteSyncError::DatabaseOperation)?;
267 if sync_parent(destination).is_err() {
268 let _ = fs::rename(destination, temporary);
269 return Err(SqliteSyncError::DatabaseOperation);
270 }
271 Ok(())
272}
273
274fn validate_paired_bundle(bundle: &Path) -> SqliteSyncResult<SqlitePairedBackupReport> {
275 let manifest_path = bundle.join("manifest.txt");
276 let manifest =
277 fs::read_to_string(&manifest_path).map_err(|_| SqliteSyncError::IntegrityFailed)?;
278 let (local_name, queue_name) = manifest_names(&manifest)?;
279 let local_path = if bundle.join("local.sqlite3").is_file() {
280 bundle.join("local.sqlite3")
281 } else {
282 bundle.join(&local_name)
283 };
284 let queue_path = if bundle.join("sync-queue.sqlite3").is_file() {
285 bundle.join("sync-queue.sqlite3")
286 } else {
287 bundle.join(&queue_name)
288 };
289 let local = validate_pair_database(&local_path, false)?;
290 let queue = validate_pair_database(&queue_path, true)?;
291 let local_connection = Connection::open_with_flags(
292 &local_path,
293 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_FULL_MUTEX,
294 )
295 .map_err(SqliteSyncError::database)?;
296 let recorded_queue_name: String = local_connection
297 .query_row(
298 "SELECT outbox_file FROM appcore_sync_storage_mode WHERE singleton=1",
299 [],
300 |row| row.get(0),
301 )
302 .map_err(SqliteSyncError::database)?;
303 if recorded_queue_name != queue_name {
304 return Err(SqliteSyncError::IntegrityFailed);
305 }
306 let connection = Connection::open_with_flags(
307 &queue_path,
308 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_FULL_MUTEX,
309 )
310 .map_err(SqliteSyncError::database)?;
311 let queue_entries: u64 = connection
312 .query_row("SELECT count(*) FROM appcore_sync_outbox", [], |row| {
313 row.get(0)
314 })
315 .map_err(SqliteSyncError::database)?;
316 let expected = format!(
317 "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",
318 local.schema_version, local.bytes, file_sha256(&local_path)?, queue.schema_version, queue.bytes, file_sha256(&queue_path)?, queue_entries
319 );
320 if manifest != expected {
321 return Err(SqliteSyncError::IntegrityFailed);
322 }
323 Ok(SqlitePairedBackupReport {
324 total_bytes: local.bytes.saturating_add(queue.bytes),
325 local,
326 queue,
327 queue_entries,
328 })
329}
330
331fn manifest_names(manifest: &str) -> SqliteSyncResult<(String, String)> {
332 let mut lines = manifest.lines();
333 if lines.next() != Some("appcore-sync-pair-v1") {
334 return Err(SqliteSyncError::UpdateRequired);
335 }
336 let mut local = None;
337 let mut queue = None;
338 for line in lines {
339 if let Some(value) = line.strip_prefix("local_file=") {
340 local = Some(safe_manifest_name(Some(value))?);
341 }
342 if let Some(value) = line.strip_prefix("queue_file=") {
343 queue = Some(safe_manifest_name(Some(value))?);
344 }
345 }
346 Ok((
347 local.ok_or(SqliteSyncError::IntegrityFailed)?,
348 queue.ok_or(SqliteSyncError::IntegrityFailed)?,
349 ))
350}
351
352fn safe_manifest_name(value: Option<&str>) -> SqliteSyncResult<String> {
353 let value = value.ok_or(SqliteSyncError::UnsafePath)?;
354 if value.is_empty()
355 || value == "."
356 || value == ".."
357 || value.contains('/')
358 || value.contains('\\')
359 || value.bytes().any(|byte| byte.is_ascii_control())
360 {
361 return Err(SqliteSyncError::UnsafePath);
362 }
363 Ok(value.to_owned())
364}
365
366fn validate_pair_database(path: &Path, queue: bool) -> SqliteSyncResult<SqliteBackupReport> {
367 if !path.is_file() {
368 return Err(SqliteSyncError::UnsafePath);
369 }
370 let connection = Connection::open_with_flags(
371 path,
372 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_FULL_MUTEX,
373 )
374 .map_err(SqliteSyncError::database)?;
375 let schema_version: u32 = connection
376 .pragma_query_value(None, "user_version", |row| row.get(0))
377 .map_err(SqliteSyncError::database)?;
378 let application_id: i64 = connection
379 .pragma_query_value(None, "application_id", |row| row.get(0))
380 .map_err(SqliteSyncError::database)?;
381 let expected_id = if queue { 0x4150_4351 } else { 0x4150_4353 };
382 if application_id != expected_id {
383 return Err(SqliteSyncError::UpdateRequired);
384 }
385 if queue {
386 let integrity: String = connection
387 .query_row("PRAGMA quick_check(1)", [], |row| row.get(0))
388 .map_err(SqliteSyncError::database)?;
389 if integrity != "ok" {
390 return Err(SqliteSyncError::IntegrityFailed);
391 }
392 } else if validate_backup_connection(&connection).is_err()
393 && !validate_local_backup_connection(&connection)?
394 {
395 return Err(SqliteSyncError::IntegrityFailed);
396 }
397 if queue {
398 let has_queue: bool = connection
399 .query_row("SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name='appcore_sync_outbox')", [], |row| row.get(0))
400 .map_err(SqliteSyncError::database)?;
401 if !has_queue || schema_version != 1 {
402 return Err(SqliteSyncError::UpdateRequired);
403 }
404 }
405 let bytes = fs::metadata(path)
406 .map_err(|_| SqliteSyncError::DatabaseOperation)?
407 .len();
408 Ok(SqliteBackupReport {
409 schema_version,
410 bytes,
411 })
412}
413
414fn validate_local_backup_connection(connection: &Connection) -> SqliteSyncResult<bool> {
415 let version: u32 = connection
416 .pragma_query_value(None, "user_version", |row| row.get(0))
417 .map_err(SqliteSyncError::database)?;
418 if !(SQLITE_SYNC_SCHEMA_V1..=SQLITE_SYNC_SCHEMA_V5).contains(&version) {
419 return Ok(false);
420 }
421 let integrity: String = connection
422 .query_row("PRAGMA quick_check(1)", [], |row| row.get(0))
423 .map_err(SqliteSyncError::database)?;
424 let mode: String = connection
425 .query_row(
426 "SELECT outbox_mode FROM appcore_sync_storage_mode WHERE singleton=1",
427 [],
428 |row| row.get(0),
429 )
430 .map_err(SqliteSyncError::database)?;
431 Ok(integrity == "ok" && mode == "separate")
432}
433
434fn file_sha256(path: &Path) -> SqliteSyncResult<String> {
435 let mut file = File::open(path).map_err(|_| SqliteSyncError::DatabaseOperation)?;
436 let mut hasher = Sha256::new();
437 let mut buffer = [0u8; 64 * 1024];
438 loop {
439 let read = file
440 .read(&mut buffer)
441 .map_err(|_| SqliteSyncError::DatabaseOperation)?;
442 if read == 0 {
443 break;
444 }
445 hasher.update(&buffer[..read]);
446 }
447 Ok(hasher
448 .finalize()
449 .iter()
450 .map(|byte| format!("{byte:02x}"))
451 .collect())
452}
453
454fn normalize_backup_destination(path: &Path) -> SqliteSyncResult<PathBuf> {
455 if path.as_os_str().is_empty() {
456 return Err(SqliteSyncError::UnsafePath);
457 }
458 let normalized = normalize_path(path)?;
459 if normalized.exists() {
460 return Err(SqliteSyncError::UnsafePath);
461 }
462 Ok(normalized)
463}
464
465fn normalize_bundle_directory(path: &Path) -> SqliteSyncResult<PathBuf> {
466 let metadata = fs::symlink_metadata(path).map_err(|_| SqliteSyncError::UnsafePath)?;
467 if !metadata.is_dir() || metadata.file_type().is_symlink() {
468 return Err(SqliteSyncError::UnsafePath);
469 }
470 fs::canonicalize(path).map_err(|_| SqliteSyncError::UnsafePath)
471}
472
473fn reserve_temporary(destination: &Path) -> SqliteSyncResult<PathBuf> {
474 let parent = destination.parent().unwrap_or_else(|| Path::new("."));
475 let name = destination
476 .file_name()
477 .and_then(|value| value.to_str())
478 .ok_or(SqliteSyncError::UnsafePath)?;
479 for attempt in 0..16u8 {
480 let candidate = parent.join(format!(".{name}.{}-{attempt}.tmp", std::process::id()));
481 match OpenOptions::new()
482 .write(true)
483 .create_new(true)
484 .open(&candidate)
485 {
486 Ok(file) => {
487 drop(file);
488 return Ok(candidate);
489 }
490 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
491 Err(_) => return Err(SqliteSyncError::DatabaseOperation),
492 }
493 }
494 Err(SqliteSyncError::CapacityExceeded("backup temporary"))
495}
496
497fn publish_temporary(temporary: &Path, destination: &Path) -> SqliteSyncResult<()> {
498 fs::hard_link(temporary, destination).map_err(|_| SqliteSyncError::DatabaseOperation)?;
499 if fs::remove_file(temporary).is_err() {
500 let _ = fs::remove_file(destination);
501 return Err(SqliteSyncError::DatabaseOperation);
502 }
503 if sync_parent(destination).is_err() {
504 let _ = fs::remove_file(destination);
505 let _ = sync_parent(destination);
506 return Err(SqliteSyncError::DatabaseOperation);
507 }
508 Ok(())
509}
510
511fn validate_backup_connection(connection: &Connection) -> SqliteSyncResult<u32> {
512 let version: u32 = connection
513 .pragma_query_value(None, "user_version", |row| row.get(0))
514 .map_err(SqliteSyncError::database)?;
515 if version >= SQLITE_SYNC_SCHEMA_V5 {
516 let mode: String = connection
517 .query_row(
518 "SELECT outbox_mode FROM appcore_sync_storage_mode WHERE singleton=1",
519 [],
520 |row| row.get(0),
521 )
522 .map_err(SqliteSyncError::database)?;
523 if mode != "local" {
524 return Err(SqliteSyncError::UpdateRequired);
525 }
526 }
527 let application_id: i64 = connection
528 .pragma_query_value(None, "application_id", |row| row.get(0))
529 .map_err(SqliteSyncError::database)?;
530 if application_id != 0x4150_4353 {
531 return Err(SqliteSyncError::UpdateRequired);
532 }
533 let integrity: String = connection
534 .query_row("PRAGMA quick_check(1)", [], |row| row.get(0))
535 .map_err(SqliteSyncError::database)?;
536 if matches!(
537 version,
538 SQLITE_SYNC_SCHEMA_V1
539 | SQLITE_SYNC_SCHEMA_V2
540 | SQLITE_SYNC_SCHEMA_V3
541 | SQLITE_SYNC_SCHEMA_V4
542 | SQLITE_SYNC_SCHEMA_V5
543 ) && integrity == "ok"
544 {
545 Ok(version)
546 } else if !matches!(
547 version,
548 SQLITE_SYNC_SCHEMA_V1
549 | SQLITE_SYNC_SCHEMA_V2
550 | SQLITE_SYNC_SCHEMA_V3
551 | SQLITE_SYNC_SCHEMA_V4
552 | SQLITE_SYNC_SCHEMA_V5
553 ) {
554 Err(SqliteSyncError::UpdateRequired)
555 } else {
556 Err(SqliteSyncError::IntegrityFailed)
557 }
558}
559
560fn sync_file(path: &Path) -> SqliteSyncResult<()> {
561 let mut options = OpenOptions::new();
562 options.read(true);
563 #[cfg(windows)]
564 options.write(true);
565 options
566 .open(path)
567 .and_then(|file| file.sync_all())
568 .map_err(|_| SqliteSyncError::DatabaseOperation)
569}
570
571#[cfg(unix)]
572fn sync_parent(path: &Path) -> SqliteSyncResult<()> {
573 let parent = path.parent().unwrap_or_else(|| Path::new("."));
574 File::open(parent)
575 .and_then(|directory| directory.sync_all())
576 .map_err(|_| SqliteSyncError::DatabaseOperation)
577}
578
579#[cfg(windows)]
580fn sync_parent(path: &Path) -> SqliteSyncResult<()> {
581 use std::os::windows::fs::OpenOptionsExt;
582 let parent = path.parent().ok_or(SqliteSyncError::UnsafePath)?;
583 OpenOptions::new()
584 .read(true)
585 .write(true)
586 .custom_flags(windows_sys::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS)
587 .open(parent)
588 .and_then(|directory| directory.sync_all())
589 .map_err(|_| SqliteSyncError::DatabaseOperation)
590}
591#[cfg(not(any(unix, windows)))]
592fn sync_parent(_path: &Path) -> SqliteSyncResult<()> {
593 Err(SqliteSyncError::DatabaseOperation)
594}