1use 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
91pub 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}