qualia_client_core/wellfair/
backup.rs1use std::collections::BTreeMap;
14use std::fs;
15use std::path::{Component, Path, PathBuf};
16
17use serde::{Deserialize, Serialize};
18
19const BACKUP_VERSION: u16 = 1;
20const WELLFAIR_SUBDIR: &str = "wellfair";
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct BackupArchive {
25 pub version: u16,
26 pub created_unix: u32,
27 pub files: BTreeMap<String, Vec<u8>>,
29}
30
31#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
33pub struct BackupReport {
34 pub files: usize,
35 pub bytes: u64,
36}
37
38fn collect_files(
39 base: &Path,
40 dir: &Path,
41 out: &mut BTreeMap<String, Vec<u8>>,
42) -> Result<(), String> {
43 for entry in fs::read_dir(dir).map_err(|e| e.to_string())? {
44 let entry = entry.map_err(|e| e.to_string())?;
45 let path = entry.path();
46 let file_type = entry.file_type().map_err(|e| e.to_string())?;
47 if file_type.is_dir() {
48 collect_files(base, &path, out)?;
49 } else if file_type.is_file() {
50 let rel = path.strip_prefix(base).map_err(|e| e.to_string())?;
51 let key = rel
52 .components()
53 .filter_map(|c| match c {
54 Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
55 _ => None,
56 })
57 .collect::<Vec<_>>()
58 .join("/");
59 if key.is_empty() {
60 continue;
61 }
62 let bytes = fs::read(&path).map_err(|e| e.to_string())?;
63 out.insert(key, bytes);
64 }
65 }
66 Ok(())
67}
68
69pub fn build_archive(storage_root: &Path, created_unix: u32) -> Result<BackupArchive, String> {
71 let base = storage_root.join(WELLFAIR_SUBDIR);
72 let mut files = BTreeMap::new();
73 if base.exists() {
74 collect_files(&base, &base, &mut files)?;
75 }
76 Ok(BackupArchive {
77 version: BACKUP_VERSION,
78 created_unix,
79 files,
80 })
81}
82
83pub fn encode_archive(archive: &BackupArchive) -> Result<Vec<u8>, String> {
85 let mut cbor = Vec::new();
86 ciborium::into_writer(archive, &mut cbor).map_err(|e| e.to_string())?;
87 Ok(lz4_flex::compress_prepend_size(&cbor))
88}
89
90pub fn decode_archive(bytes: &[u8]) -> Result<BackupArchive, String> {
92 let cbor = lz4_flex::decompress_size_prepended(bytes).map_err(|e| e.to_string())?;
93 let archive: BackupArchive = ciborium::from_reader(&cbor[..]).map_err(|e| e.to_string())?;
94 if archive.version != BACKUP_VERSION {
95 return Err(format!(
96 "unsupported backup version {} (expected {BACKUP_VERSION})",
97 archive.version
98 ));
99 }
100 Ok(archive)
101}
102
103fn safe_join(base: &Path, rel: &str) -> Result<PathBuf, String> {
106 let mut out = base.to_path_buf();
107 for comp in Path::new(rel).components() {
108 match comp {
109 Component::Normal(c) => out.push(c),
110 _ => return Err(format!("unsafe path in backup archive: '{rel}'")),
111 }
112 }
113 Ok(out)
114}
115
116pub fn restore_archive(
118 storage_root: &Path,
119 archive: &BackupArchive,
120) -> Result<BackupReport, String> {
121 let base = storage_root.join(WELLFAIR_SUBDIR);
122 let mut report = BackupReport::default();
123 for (rel, bytes) in &archive.files {
124 let target = safe_join(&base, rel)?;
125 if let Some(parent) = target.parent() {
126 fs::create_dir_all(parent).map_err(|e| e.to_string())?;
127 }
128 fs::write(&target, bytes).map_err(|e| e.to_string())?;
129 report.files += 1;
130 report.bytes += bytes.len() as u64;
131 }
132 Ok(report)
133}
134
135pub fn create_backup(storage_root: &Path, created_unix: u32) -> Result<Vec<u8>, String> {
137 encode_archive(&build_archive(storage_root, created_unix)?)
138}
139
140pub fn restore_backup(storage_root: &Path, bytes: &[u8]) -> Result<BackupReport, String> {
142 restore_archive(storage_root, &decode_archive(bytes)?)
143}
144
145fn stat_dir(dir: &Path, files: &mut usize, bytes: &mut u64) -> Result<(), String> {
146 for entry in fs::read_dir(dir).map_err(|e| e.to_string())? {
147 let entry = entry.map_err(|e| e.to_string())?;
148 let file_type = entry.file_type().map_err(|e| e.to_string())?;
149 if file_type.is_dir() {
150 stat_dir(&entry.path(), files, bytes)?;
151 } else if file_type.is_file() {
152 *files += 1;
153 *bytes += entry.metadata().map_err(|e| e.to_string())?.len();
154 }
155 }
156 Ok(())
157}
158
159pub fn wellfair_data_stats(storage_root: &Path) -> Result<(usize, u64), String> {
161 let base = storage_root.join(WELLFAIR_SUBDIR);
162 let mut files = 0;
163 let mut bytes = 0u64;
164 if base.exists() {
165 stat_dir(&base, &mut files, &mut bytes)?;
166 }
167 Ok((files, bytes))
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 fn write(root: &Path, rel: &str, contents: &[u8]) {
175 let p = root.join(WELLFAIR_SUBDIR).join(rel);
176 fs::create_dir_all(p.parent().unwrap()).unwrap();
177 fs::write(p, contents).unwrap();
178 }
179
180 #[test]
181 fn backup_round_trips_the_wellfair_subtree() {
182 let src = tempfile::tempdir().unwrap();
183 write(src.path(), "journal.jsonl", b"{\"kind\":\"weight\"}\n");
184 write(
185 src.path(),
186 "sanctuary_vault.cbor",
187 &[0xDE, 0xAD, 0xBE, 0xEF],
188 );
189 write(src.path(), "blobs/aa/bb.bin", b"blobbytes");
190
191 let bytes = create_backup(src.path(), 1_700_000_000).unwrap();
192
193 let dst = tempfile::tempdir().unwrap();
195 let report = restore_backup(dst.path(), &bytes).unwrap();
196 assert_eq!(report.files, 3);
197
198 let base = dst.path().join(WELLFAIR_SUBDIR);
200 assert_eq!(
201 fs::read(base.join("journal.jsonl")).unwrap(),
202 b"{\"kind\":\"weight\"}\n"
203 );
204 assert_eq!(
205 fs::read(base.join("sanctuary_vault.cbor")).unwrap(),
206 vec![0xDE, 0xAD, 0xBE, 0xEF]
207 );
208 assert_eq!(
209 fs::read(base.join("blobs/aa/bb.bin")).unwrap(),
210 b"blobbytes"
211 );
212 }
213
214 #[test]
215 fn empty_root_yields_empty_archive() {
216 let src = tempfile::tempdir().unwrap();
217 let archive = build_archive(src.path(), 1).unwrap();
218 assert!(archive.files.is_empty());
219 let bytes = encode_archive(&archive).unwrap();
221 assert_eq!(decode_archive(&bytes).unwrap(), archive);
222 }
223
224 #[test]
225 fn restore_rejects_path_traversal_keys() {
226 let mut files = BTreeMap::new();
227 files.insert("../escape.txt".to_string(), b"evil".to_vec());
228 let archive = BackupArchive {
229 version: BACKUP_VERSION,
230 created_unix: 1,
231 files,
232 };
233 let dst = tempfile::tempdir().unwrap();
234 assert!(restore_archive(dst.path(), &archive).is_err());
235 assert!(!dst.path().join("escape.txt").exists());
237 }
238
239 #[test]
240 fn decode_rejects_wrong_version() {
241 let archive = BackupArchive {
242 version: 999,
243 created_unix: 1,
244 files: BTreeMap::new(),
245 };
246 let bytes = encode_archive(&archive).unwrap();
247 assert!(decode_archive(&bytes).is_err());
248 }
249
250 #[test]
251 fn decode_rejects_garbage() {
252 assert!(decode_archive(b"not an archive at all").is_err());
253 }
254}