Skip to main content

qualia_client_core/wellfair/
backup.rs

1//! Portable **backup / restore** of the WellFair data subtree (T3.3 release-hardening slice).
2//!
3//! A backup is a single archive — `lz4(cbor(BackupArchive))` — of every file under
4//! `<storage_root>/wellfair/`: the journal, receipts, sync outbox/inbox, consent + blob stores, and
5//! the encrypted Sanctuary vault. The Sanctuary vault is already AEAD-encrypted at rest, so it stays
6//! encrypted inside the archive; the rest carries the same at-rest posture as on disk (an optional
7//! passphrase wrapper over the whole archive is a clean follow-up).
8//!
9//! Restore is **path-traversal-safe**: every archive key must be a relative path whose components are
10//! all `Normal` (no `..`, no root, no drive prefix), so a malicious archive can never write outside
11//! the target `wellfair/` directory.
12
13use 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/// The decoded archive: a version + creation stamp + a map of relative path → file bytes.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct BackupArchive {
25    pub version: u16,
26    pub created_unix: u32,
27    /// Forward-slash relative paths under `wellfair/` → file contents.
28    pub files: BTreeMap<String, Vec<u8>>,
29}
30
31/// A count of what an export/import moved.
32#[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
69/// Build an in-memory archive of the `wellfair/` subtree under `storage_root`.
70pub 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
83/// Encode an archive to its on-disk form: `lz4(cbor(archive))`.
84pub 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
90/// Decode an archive from its on-disk form.
91pub 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
103/// Join a relative archive key under `base`, **rejecting** any non-`Normal` component (path-traversal
104/// defense: no `..`, no absolute/root, no drive prefix).
105fn 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
116/// Restore an archive into the `wellfair/` subtree under `storage_root` (creating directories).
117pub 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
135/// Convenience: build + encode a backup of `storage_root`'s WellFair data.
136pub fn create_backup(storage_root: &Path, created_unix: u32) -> Result<Vec<u8>, String> {
137    encode_archive(&build_archive(storage_root, created_unix)?)
138}
139
140/// Convenience: decode + restore a backup into `storage_root`.
141pub 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
159/// Cheap `(file count, total bytes)` of the `wellfair/` data subtree — metadata only, no file reads.
160pub 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        // Restore into a fresh, empty root.
194        let dst = tempfile::tempdir().unwrap();
195        let report = restore_backup(dst.path(), &bytes).unwrap();
196        assert_eq!(report.files, 3);
197
198        // Every file is byte-identical after the round trip.
199        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        // Encode/decode of an empty archive still round-trips.
220        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        // Nothing escaped the target directory.
236        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}