Skip to main content

microscope_memory/
snapshot.rs

1//! Snapshot: .mscope archive format for backup/restore/diff.
2//!
3//! Format:
4//!   [magic "MSEX" 4B][version u32][file_count u32]
5//!   Per file: [name_len u16][name bytes][data_len u64][data bytes]
6
7use std::fs;
8use std::io::{BufReader, BufWriter, Read, Write};
9use std::path::Path;
10
11const MAGIC: &[u8; 4] = b"MSEX";
12const VERSION: u32 = 1;
13
14/// Files that compose a microscope index.
15const INDEX_FILES: &[&str] = &[
16    "meta.bin",
17    "microscope.bin",
18    "data.bin",
19    "merkle.bin",
20    "append.bin",
21    "embeddings.bin",
22    "fingerprints.idx",
23    "links.bin",
24    "emotions.bin",
25];
26
27/// Export all index files from output_dir into a single .mscope archive.
28pub fn export(output_dir: &Path, archive_path: &Path) -> Result<(), String> {
29    let mut files: Vec<(String, Vec<u8>)> = Vec::new();
30
31    for &name in INDEX_FILES {
32        let path = output_dir.join(name);
33        if path.exists() {
34            let data = fs::read(&path).map_err(|e| format!("read {}: {}", name, e))?;
35            files.push((name.to_string(), data));
36        }
37    }
38
39    if files.is_empty() {
40        return Err("no index files found to export".to_string());
41    }
42
43    // Atomic write: temp file + rename to prevent partial/corrupt archive on crash or disk-full
44    let tmp_path = archive_path.with_extension("mscope.tmp");
45    let f = fs::File::create(&tmp_path).map_err(|e| format!("create archive: {}", e))?;
46    let mut w = BufWriter::new(f);
47
48    // Header
49    w.write_all(MAGIC).map_err(|e| e.to_string())?;
50    w.write_all(&VERSION.to_le_bytes())
51        .map_err(|e| e.to_string())?;
52    w.write_all(&(files.len() as u32).to_le_bytes())
53        .map_err(|e| e.to_string())?;
54
55    // Files
56    let mut total_size = 12u64; // header
57    for (name, data) in &files {
58        let name_bytes = name.as_bytes();
59        w.write_all(&(name_bytes.len() as u16).to_le_bytes())
60            .map_err(|e| e.to_string())?;
61        w.write_all(name_bytes).map_err(|e| e.to_string())?;
62        w.write_all(&(data.len() as u64).to_le_bytes())
63            .map_err(|e| e.to_string())?;
64        w.write_all(data).map_err(|e| e.to_string())?;
65        total_size += 2 + name_bytes.len() as u64 + 8 + data.len() as u64;
66    }
67
68    w.flush().map_err(|e| e.to_string())?;
69    drop(w);
70    fs::rename(&tmp_path, archive_path).map_err(|e| format!("rename archive: {}", e))?;
71
72    println!(
73        "  Exported {} files ({:.1} KB) → {}",
74        files.len(),
75        total_size as f64 / 1024.0,
76        archive_path.display()
77    );
78    for (name, data) in &files {
79        println!("    {}: {:.1} KB", name, data.len() as f64 / 1024.0);
80    }
81
82    Ok(())
83}
84
85/// Import a .mscope archive into output_dir.
86pub fn import(archive_path: &Path, output_dir: &Path) -> Result<(), String> {
87    let f = fs::File::open(archive_path).map_err(|e| format!("open archive: {}", e))?;
88    let mut r = BufReader::new(f);
89
90    // Header
91    let mut magic = [0u8; 4];
92    r.read_exact(&mut magic).map_err(|e| e.to_string())?;
93    if &magic != MAGIC {
94        return Err(format!("invalid magic: expected MSEX, got {:?}", magic));
95    }
96
97    let mut ver_buf = [0u8; 4];
98    r.read_exact(&mut ver_buf).map_err(|e| e.to_string())?;
99    let version = u32::from_le_bytes(ver_buf);
100    if version > VERSION {
101        return Err(format!(
102            "unsupported version: {} (max: {})",
103            version, VERSION
104        ));
105    }
106
107    let mut count_buf = [0u8; 4];
108    r.read_exact(&mut count_buf).map_err(|e| e.to_string())?;
109    let file_count = u32::from_le_bytes(count_buf);
110
111    fs::create_dir_all(output_dir).map_err(|e| format!("create output dir: {}", e))?;
112
113    for _ in 0..file_count {
114        // Name
115        let mut name_len_buf = [0u8; 2];
116        r.read_exact(&mut name_len_buf).map_err(|e| e.to_string())?;
117        let name_len = u16::from_le_bytes(name_len_buf) as usize;
118        let mut name_buf = vec![0u8; name_len];
119        r.read_exact(&mut name_buf).map_err(|e| e.to_string())?;
120        let name = String::from_utf8(name_buf).map_err(|e| e.to_string())?;
121
122        // Data
123        let mut data_len_buf = [0u8; 8];
124        r.read_exact(&mut data_len_buf).map_err(|e| e.to_string())?;
125        let data_len = u64::from_le_bytes(data_len_buf) as usize;
126        let mut data = vec![0u8; data_len];
127        r.read_exact(&mut data).map_err(|e| e.to_string())?;
128
129        // Sanitize filename (only allow known index files)
130        if !INDEX_FILES.contains(&name.as_str()) {
131            println!("    Skipping unknown file: {}", name);
132            continue;
133        }
134
135        let out_path = output_dir.join(&name);
136        let out_tmp = output_dir.join(format!("{}.tmp", name));
137        fs::write(&out_tmp, &data).map_err(|e| format!("write {}: {}", name, e))?;
138        fs::rename(&out_tmp, &out_path).map_err(|e| format!("rename {}: {}", name, e))?;
139        println!("    {}: {:.1} KB", name, data.len() as f64 / 1024.0);
140    }
141
142    println!("  Imported {} files → {}", file_count, output_dir.display());
143    Ok(())
144}
145
146/// Compare two .mscope archives: Merkle root + per-file size diff.
147pub fn diff(a_path: &Path, b_path: &Path) -> Result<(), String> {
148    let a_files = read_archive(a_path)?;
149    let b_files = read_archive(b_path)?;
150
151    println!("  {} vs {}", a_path.display(), b_path.display());
152
153    // Compare Merkle roots if both have meta.bin
154    let a_root = extract_merkle_root(&a_files);
155    let b_root = extract_merkle_root(&b_files);
156    match (a_root, b_root) {
157        (Some(ar), Some(br)) => {
158            if ar == br {
159                println!("  Merkle root: {} (identical)", hex_str(&ar));
160            } else {
161                println!("  Merkle root A: {}", hex_str(&ar));
162                println!("  Merkle root B: {}", hex_str(&br));
163                println!("  DIFF Merkle roots differ — data changed");
164            }
165        }
166        _ => println!("  (cannot compare Merkle roots — meta.bin missing)"),
167    }
168
169    // Per-file size comparison
170    let all_names: std::collections::BTreeSet<&str> = a_files
171        .keys()
172        .chain(b_files.keys())
173        .map(|s| s.as_str())
174        .collect();
175
176    for name in all_names {
177        let a_size = a_files.get(name).map(|d| d.len());
178        let b_size = b_files.get(name).map(|d| d.len());
179        match (a_size, b_size) {
180            (Some(a), Some(b)) => {
181                let delta = b as i64 - a as i64;
182                let sign = if delta >= 0 { "+" } else { "" };
183                let status = if a == b { "=" } else { "~" };
184                println!(
185                    "  {} {}: {} → {} ({}{} bytes)",
186                    status, name, a, b, sign, delta
187                );
188            }
189            (Some(a), None) => println!("  - {}: {} (removed)", name, a),
190            (None, Some(b)) => println!("  + {}: {} (added)", name, b),
191            (None, None) => {}
192        }
193    }
194
195    // Block count comparison
196    let a_blocks = extract_block_count(&a_files);
197    let b_blocks = extract_block_count(&b_files);
198    if let (Some(a), Some(b)) = (a_blocks, b_blocks) {
199        println!(
200            "  Blocks: {} → {} ({}{})",
201            a,
202            b,
203            if b >= a { "+" } else { "" },
204            b as i64 - a as i64
205        );
206    }
207
208    Ok(())
209}
210
211fn read_archive(path: &Path) -> Result<std::collections::HashMap<String, Vec<u8>>, String> {
212    let f = fs::File::open(path).map_err(|e| format!("open {}: {}", path.display(), e))?;
213    let mut r = BufReader::new(f);
214    let mut magic = [0u8; 4];
215    r.read_exact(&mut magic).map_err(|e| e.to_string())?;
216    if &magic != MAGIC {
217        return Err(format!("invalid magic in {}", path.display()));
218    }
219
220    let mut ver_buf = [0u8; 4];
221    r.read_exact(&mut ver_buf).map_err(|e| e.to_string())?;
222    let mut count_buf = [0u8; 4];
223    r.read_exact(&mut count_buf).map_err(|e| e.to_string())?;
224    let file_count = u32::from_le_bytes(count_buf);
225
226    let mut files = std::collections::HashMap::new();
227    for _ in 0..file_count {
228        let mut name_len_buf = [0u8; 2];
229        r.read_exact(&mut name_len_buf).map_err(|e| e.to_string())?;
230        let name_len = u16::from_le_bytes(name_len_buf) as usize;
231        let mut name_buf = vec![0u8; name_len];
232        r.read_exact(&mut name_buf).map_err(|e| e.to_string())?;
233        let name = String::from_utf8(name_buf).map_err(|e| e.to_string())?;
234
235        let mut data_len_buf = [0u8; 8];
236        r.read_exact(&mut data_len_buf).map_err(|e| e.to_string())?;
237        let data_len = u64::from_le_bytes(data_len_buf) as usize;
238        let mut data = vec![0u8; data_len];
239        r.read_exact(&mut data).map_err(|e| e.to_string())?;
240
241        files.insert(name, data);
242    }
243    Ok(files)
244}
245
246fn extract_merkle_root(files: &std::collections::HashMap<String, Vec<u8>>) -> Option<[u8; 32]> {
247    let meta = files.get("meta.bin")?;
248    if meta.len() < 4 || &meta[0..4] != b"MSC2" {
249        return None;
250    }
251    let offset = crate::META_HEADER_SIZE + 9 * crate::DEPTH_ENTRY_SIZE;
252    if meta.len() < offset + 32 {
253        return None;
254    }
255    let mut root = [0u8; 32];
256    root.copy_from_slice(&meta[offset..offset + 32]);
257    Some(root)
258}
259
260fn extract_block_count(files: &std::collections::HashMap<String, Vec<u8>>) -> Option<u32> {
261    let meta = files.get("meta.bin")?;
262    if meta.len() < 12 {
263        return None;
264    }
265    Some(u32::from_le_bytes(meta[8..12].try_into().ok()?))
266}
267
268fn hex_str(bytes: &[u8]) -> String {
269    bytes
270        .iter()
271        .map(|b| format!("{:02x}", b))
272        .collect::<Vec<_>>()
273        .join("")
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    #[test]
280    fn test_export_import_roundtrip() {
281        let dir = std::env::temp_dir().join("mscope_snap_test");
282        let _ = fs::create_dir_all(&dir);
283
284        // Create fake index files
285        let src_dir = dir.join("src");
286        let _ = fs::create_dir_all(&src_dir);
287        fs::write(src_dir.join("meta.bin"), b"MSC2testdata1234").unwrap();
288        fs::write(src_dir.join("microscope.bin"), b"headers_here").unwrap();
289        fs::write(src_dir.join("data.bin"), b"block_data_here").unwrap();
290
291        // Export
292        let archive = dir.join("test.mscope");
293        export(&src_dir, &archive).unwrap();
294        assert!(archive.exists());
295
296        // Import
297        let dst_dir = dir.join("dst");
298        import(&archive, &dst_dir).unwrap();
299
300        // Verify
301        assert_eq!(
302            fs::read(dst_dir.join("meta.bin")).unwrap(),
303            b"MSC2testdata1234"
304        );
305        assert_eq!(
306            fs::read(dst_dir.join("microscope.bin")).unwrap(),
307            b"headers_here"
308        );
309        assert_eq!(
310            fs::read(dst_dir.join("data.bin")).unwrap(),
311            b"block_data_here"
312        );
313
314        let _ = fs::remove_dir_all(&dir);
315    }
316}