Skip to main content

appcore_sync_sqlite/
backup.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: backup.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/26 00:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/26 00:00:00 by dnettoRaw
8//      ###########      S: 2.0.0
9// =============================================================================
10
11//! Defines bounded backup contracts and behavior for this crate.
12
13use crate::store::{configure_memory, normalize_path};
14use crate::{
15    SqliteSyncError, SqliteSyncResult, SqliteSyncStore, SQLITE_SYNC_SCHEMA_V1,
16    SQLITE_SYNC_SCHEMA_V2,
17};
18use rusqlite::backup::Backup;
19use rusqlite::{Connection, OpenFlags};
20use std::fs::{self, File, OpenOptions};
21use std::path::{Path, PathBuf};
22use std::time::Duration;
23
24/// Evidence returned after an integrity-checked online backup.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct SqliteBackupReport {
27    /// Internal schema version copied to the backup.
28    pub schema_version: u32,
29    /// Final backup file size in bytes.
30    pub bytes: u64,
31}
32
33impl SqliteSyncStore {
34    /// Creates an integrity-checked online backup and atomically publishes it.
35    pub fn online_backup(
36        &self,
37        destination: impl AsRef<Path>,
38    ) -> SqliteSyncResult<SqliteBackupReport> {
39        let destination = normalize_backup_destination(destination.as_ref())?;
40        let temporary = reserve_temporary(&destination)?;
41        let result = self.copy_backup(&temporary).and_then(|report| {
42            sync_file(&temporary)?;
43            publish_temporary(&temporary, &destination)?;
44            Ok(report)
45        });
46        if result.is_err() {
47            let _ = fs::remove_file(&temporary);
48        }
49        result
50    }
51
52    fn copy_backup(&self, temporary: &Path) -> SqliteSyncResult<SqliteBackupReport> {
53        self.with_connection(|source| {
54            copy_connection(source, temporary, self.config().backup_pages_per_step)
55        })
56    }
57
58    /// Restores a verified backup into a new, previously absent database path.
59    pub fn restore_backup_to_new(
60        backup_path: impl AsRef<Path>,
61        destination: impl AsRef<Path>,
62    ) -> SqliteSyncResult<SqliteBackupReport> {
63        let original_backup_path = backup_path.as_ref();
64        let backup_path = normalize_path(original_backup_path)?;
65        if !backup_path.is_file() {
66            return Err(SqliteSyncError::UnsafePath);
67        }
68        let destination = normalize_backup_destination(destination.as_ref())?;
69        let source = Connection::open_with_flags(
70            &backup_path,
71            OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_FULL_MUTEX,
72        )
73        .map_err(SqliteSyncError::database)?;
74        configure_memory(&source)?;
75        validate_backup_connection(&source)?;
76        let temporary = reserve_temporary(&destination)?;
77        let result = copy_connection(&source, &temporary, 128).and_then(|report| {
78            sync_file(&temporary)?;
79            publish_temporary(&temporary, &destination)?;
80            Ok(report)
81        });
82        if result.is_err() {
83            let _ = fs::remove_file(&temporary);
84        }
85        result
86    }
87}
88
89fn copy_connection(
90    source: &Connection,
91    temporary: &Path,
92    pages_per_step: i32,
93) -> SqliteSyncResult<SqliteBackupReport> {
94    let flags = OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_FULL_MUTEX;
95    let mut destination =
96        Connection::open_with_flags(temporary, flags).map_err(SqliteSyncError::database)?;
97    configure_memory(&destination)?;
98    let backup = Backup::new(source, &mut destination).map_err(SqliteSyncError::database)?;
99    backup
100        .run_to_completion(pages_per_step, Duration::from_millis(2), None)
101        .map_err(SqliteSyncError::database)?;
102    drop(backup);
103    let schema_version = validate_backup_connection(&destination)?;
104    drop(destination);
105    let bytes = fs::metadata(temporary)
106        .map_err(|_| SqliteSyncError::DatabaseOperation)?
107        .len();
108    Ok(SqliteBackupReport {
109        schema_version,
110        bytes,
111    })
112}
113
114fn normalize_backup_destination(path: &Path) -> SqliteSyncResult<PathBuf> {
115    if path.as_os_str().is_empty() {
116        return Err(SqliteSyncError::UnsafePath);
117    }
118    let normalized = normalize_path(path)?;
119    if normalized.exists() {
120        return Err(SqliteSyncError::UnsafePath);
121    }
122    Ok(normalized)
123}
124
125fn reserve_temporary(destination: &Path) -> SqliteSyncResult<PathBuf> {
126    let parent = destination.parent().unwrap_or_else(|| Path::new("."));
127    let name = destination
128        .file_name()
129        .and_then(|value| value.to_str())
130        .ok_or(SqliteSyncError::UnsafePath)?;
131    for attempt in 0..16u8 {
132        let candidate = parent.join(format!(".{name}.{}-{attempt}.tmp", std::process::id()));
133        match OpenOptions::new()
134            .write(true)
135            .create_new(true)
136            .open(&candidate)
137        {
138            Ok(file) => {
139                drop(file);
140                return Ok(candidate);
141            }
142            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
143            Err(_) => return Err(SqliteSyncError::DatabaseOperation),
144        }
145    }
146    Err(SqliteSyncError::CapacityExceeded("backup temporary"))
147}
148
149fn publish_temporary(temporary: &Path, destination: &Path) -> SqliteSyncResult<()> {
150    fs::hard_link(temporary, destination).map_err(|_| SqliteSyncError::DatabaseOperation)?;
151    if fs::remove_file(temporary).is_err() {
152        let _ = fs::remove_file(destination);
153        return Err(SqliteSyncError::DatabaseOperation);
154    }
155    if sync_parent(destination).is_err() {
156        let _ = fs::remove_file(destination);
157        let _ = sync_parent(destination);
158        return Err(SqliteSyncError::DatabaseOperation);
159    }
160    Ok(())
161}
162
163fn validate_backup_connection(connection: &Connection) -> SqliteSyncResult<u32> {
164    let version: u32 = connection
165        .pragma_query_value(None, "user_version", |row| row.get(0))
166        .map_err(SqliteSyncError::database)?;
167    let integrity: String = connection
168        .query_row("PRAGMA quick_check(1)", [], |row| row.get(0))
169        .map_err(SqliteSyncError::database)?;
170    if matches!(version, SQLITE_SYNC_SCHEMA_V1 | SQLITE_SYNC_SCHEMA_V2) && integrity == "ok" {
171        Ok(version)
172    } else if !matches!(version, SQLITE_SYNC_SCHEMA_V1 | SQLITE_SYNC_SCHEMA_V2) {
173        Err(SqliteSyncError::UpdateRequired)
174    } else {
175        Err(SqliteSyncError::IntegrityFailed)
176    }
177}
178
179fn sync_file(path: &Path) -> SqliteSyncResult<()> {
180    File::open(path)
181        .and_then(|file| file.sync_all())
182        .map_err(|_| SqliteSyncError::DatabaseOperation)
183}
184
185#[cfg(unix)]
186fn sync_parent(path: &Path) -> SqliteSyncResult<()> {
187    let parent = path.parent().unwrap_or_else(|| Path::new("."));
188    File::open(parent)
189        .and_then(|directory| directory.sync_all())
190        .map_err(|_| SqliteSyncError::DatabaseOperation)
191}
192
193#[cfg(not(unix))]
194fn sync_parent(_path: &Path) -> SqliteSyncResult<()> {
195    Ok(())
196}