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