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