Skip to main content

mongreldb_core/
backup.rs

1//! Online backup manifest and verification.
2
3use crate::{MongrelError, Result};
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use std::io::Read;
7use std::path::{Component, Path, PathBuf};
8
9pub const BACKUP_FORMAT_VERSION: u16 = 1;
10pub const BACKUP_MANIFEST_PATH: &str = "_meta/backup.json";
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct BackupFile {
14    pub path: PathBuf,
15    pub bytes: u64,
16    pub sha256: String,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct BackupManifest {
21    pub format_version: u16,
22    pub epoch: u64,
23    pub created_unix_nanos: u64,
24    pub files: Vec<BackupFile>,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct BackupReport {
29    pub destination: PathBuf,
30    pub epoch: u64,
31    pub files: usize,
32    pub bytes: u64,
33}
34
35impl BackupManifest {
36    pub(crate) fn create(root: &Path, epoch: u64, paths: &[PathBuf]) -> Result<Self> {
37        let mut paths = paths.to_vec();
38        paths.sort();
39        paths.dedup();
40        let mut files = Vec::with_capacity(paths.len());
41        for path in paths {
42            validate_relative_path(&path)?;
43            let absolute = root.join(&path);
44            let metadata = std::fs::metadata(&absolute)?;
45            if !metadata.is_file() {
46                return Err(MongrelError::InvalidArgument(format!(
47                    "backup entry is not a file: {}",
48                    path.display()
49                )));
50            }
51            files.push(BackupFile {
52                path,
53                bytes: metadata.len(),
54                sha256: sha256_file(&absolute)?,
55            });
56        }
57        Ok(Self {
58            format_version: BACKUP_FORMAT_VERSION,
59            epoch,
60            created_unix_nanos: std::time::SystemTime::now()
61                .duration_since(std::time::UNIX_EPOCH)
62                .unwrap_or_default()
63                .as_nanos() as u64,
64            files,
65        })
66    }
67
68    pub(crate) fn write(&self, root: &Path) -> Result<()> {
69        let path = root.join(BACKUP_MANIFEST_PATH);
70        let parent = path
71            .parent()
72            .ok_or_else(|| MongrelError::InvalidArgument("invalid backup manifest path".into()))?;
73        std::fs::create_dir_all(parent)?;
74        let bytes = serde_json::to_vec_pretty(self)
75            .map_err(|error| MongrelError::Other(format!("backup manifest encode: {error}")))?;
76        let mut file = std::fs::OpenOptions::new()
77            .create_new(true)
78            .write(true)
79            .open(path)?;
80        use std::io::Write;
81        file.write_all(&bytes)?;
82        file.sync_all()?;
83        Ok(())
84    }
85
86    pub fn total_bytes(&self) -> u64 {
87        self.files.iter().map(|file| file.bytes).sum()
88    }
89}
90
91/// Verify a backup manifest, every listed file size/hash, and the catalog.
92pub fn verify_backup(root: impl AsRef<Path>) -> Result<BackupManifest> {
93    let root = root.as_ref();
94    let manifest: BackupManifest = serde_json::from_slice(&std::fs::read(
95        root.join(BACKUP_MANIFEST_PATH),
96    )?)
97    .map_err(|error| MongrelError::InvalidArgument(format!("invalid backup manifest: {error}")))?;
98    if manifest.format_version != BACKUP_FORMAT_VERSION {
99        return Err(MongrelError::InvalidArgument(format!(
100            "unsupported backup format version {}",
101            manifest.format_version
102        )));
103    }
104    if !root.join(crate::catalog::CATALOG_FILENAME).is_file() {
105        return Err(MongrelError::InvalidArgument(
106            "backup has no catalog".into(),
107        ));
108    }
109    for file in &manifest.files {
110        validate_relative_path(&file.path)?;
111        let path = root.join(&file.path);
112        let metadata = std::fs::metadata(&path).map_err(|error| {
113            MongrelError::Other(format!("backup file {}: {error}", file.path.display()))
114        })?;
115        if metadata.len() != file.bytes {
116            return Err(MongrelError::Other(format!(
117                "backup file {} size mismatch: expected {}, got {}",
118                file.path.display(),
119                file.bytes,
120                metadata.len()
121            )));
122        }
123        let actual = sha256_file(&path)?;
124        if actual != file.sha256 {
125            return Err(MongrelError::Other(format!(
126                "backup file {} checksum mismatch",
127                file.path.display()
128            )));
129        }
130    }
131    Ok(manifest)
132}
133
134pub(crate) fn copy_file_synced(source: &Path, destination: &Path) -> Result<u64> {
135    let parent = destination
136        .parent()
137        .ok_or_else(|| MongrelError::InvalidArgument("invalid backup file path".into()))?;
138    std::fs::create_dir_all(parent)?;
139    let bytes = std::fs::copy(source, destination)?;
140    std::fs::File::open(destination)?.sync_all()?;
141    Ok(bytes)
142}
143
144pub(crate) fn sync_directories(root: &Path) -> Result<()> {
145    let mut directories = vec![root.to_path_buf()];
146    collect_directories(root, &mut directories)?;
147    directories.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
148    for directory in directories {
149        std::fs::File::open(directory)?.sync_all()?;
150    }
151    Ok(())
152}
153
154fn collect_directories(directory: &Path, output: &mut Vec<PathBuf>) -> Result<()> {
155    for entry in std::fs::read_dir(directory)? {
156        let entry = entry?;
157        if entry.file_type()?.is_dir() {
158            output.push(entry.path());
159            collect_directories(&entry.path(), output)?;
160        }
161    }
162    Ok(())
163}
164
165fn sha256_file(path: &Path) -> Result<String> {
166    let mut file = std::fs::File::open(path)?;
167    let mut hasher = Sha256::new();
168    let mut buffer = [0u8; 64 * 1024];
169    loop {
170        let read = file.read(&mut buffer)?;
171        if read == 0 {
172            break;
173        }
174        hasher.update(&buffer[..read]);
175    }
176    Ok(hasher
177        .finalize()
178        .iter()
179        .map(|byte| format!("{byte:02x}"))
180        .collect())
181}
182
183fn validate_relative_path(path: &Path) -> Result<()> {
184    if path.as_os_str().is_empty()
185        || path.is_absolute()
186        || path.components().any(|component| {
187            matches!(
188                component,
189                Component::ParentDir | Component::RootDir | Component::Prefix(_)
190            )
191        })
192    {
193        return Err(MongrelError::InvalidArgument(format!(
194            "invalid backup path {}",
195            path.display()
196        )));
197    }
198    Ok(())
199}