Skip to main content

ninox_core/
brain_archive.rs

1//! Export/import a brain as a portable `.tar.gz` archive.
2//!
3//! Packages only the Markdown source files (the human-readable source of
4//! truth, see `docs/BRAIN.md`) — never `.index.db`, which is derived and
5//! machine/version-specific. A plain gzipped tarball, not a bespoke format,
6//! so a teammate without `ninox` can `tar xzf` it by hand if they want.
7
8use anyhow::{bail, Context, Result};
9use flate2::{read::GzDecoder, write::GzEncoder, Compression};
10use std::{
11    fs,
12    path::{Path, PathBuf},
13};
14use walkdir::WalkDir;
15
16/// Result of [`export`].
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub struct ExportStats {
19    /// Markdown files packaged into the archive.
20    pub files: usize,
21}
22
23/// Result of [`import`].
24#[derive(Debug, Clone, Default)]
25pub struct ImportStats {
26    /// Entries extracted into the target brain.
27    pub imported: usize,
28    /// Relative paths that already existed in the target brain and were
29    /// left untouched because `force` was not set.
30    pub skipped: Vec<PathBuf>,
31    /// Relative paths that failed to extract (e.g. a `--force` overwrite of
32    /// a file by a same-named directory, or vice versa) along with the
33    /// error. Recorded rather than aborting so one bad entry doesn't lose
34    /// the rest of an otherwise-good import.
35    pub failed: Vec<(PathBuf, String)>,
36}
37
38/// Package every Markdown file under `brain_path` into a gzipped tarball at
39/// `output_path`, preserving relative paths (e.g. `repos/ninox.md`).
40///
41/// Creates `brain_path` if it doesn't exist yet (mirrors `BrainIndex::open`),
42/// so exporting a brain that has never been indexed produces an empty
43/// archive instead of a raw "no such file or directory" error.
44pub fn export(brain_path: &Path, output_path: &Path) -> Result<ExportStats> {
45    fs::create_dir_all(brain_path).with_context(|| format!("create brain dir {brain_path:?}"))?;
46
47    let out_file = fs::File::create(output_path)
48        .with_context(|| format!("create archive {output_path:?}"))?;
49    let mut builder = tar::Builder::new(GzEncoder::new(out_file, Compression::default()));
50    // Never dereference a symlink into the archive as a regular file's
51    // content — matches `WalkDir::follow_links(false)` below in intent, and
52    // means `import` can safely refuse every symlink entry it sees without
53    // ever rejecting a legitimate export.
54    builder.follow_symlinks(false);
55
56    let mut files = 0usize;
57    for entry in WalkDir::new(brain_path).follow_links(false).sort_by_file_name() {
58        let entry = entry.with_context(|| format!("walk brain dir {brain_path:?}"))?;
59        let path = entry.path();
60        if entry.file_type().is_symlink()
61            || !path.is_file()
62            || path.extension().and_then(|e| e.to_str()) != Some("md")
63        {
64            continue;
65        }
66        let rel = path
67            .strip_prefix(brain_path)
68            .with_context(|| format!("relativize {path:?} against {brain_path:?}"))?;
69        builder
70            .append_path_with_name(path, rel)
71            .with_context(|| format!("add {rel:?} to archive"))?;
72        files += 1;
73    }
74
75    let enc = builder.into_inner().context("finish tar stream")?;
76    enc.finish().context("finish gzip stream")?;
77    Ok(ExportStats { files })
78}
79
80/// Reject an entry whose path or type could let extraction escape
81/// `target_brain_path` — required because `Entry::unpack`, unlike
82/// `Entry::unpack_in`, does not guard against this itself. An absolute path
83/// or a `..` component could point anywhere on disk once joined onto the
84/// target dir, and a symlink/hard-link entry could redirect a *later*
85/// same-named entry through it.
86fn check_entry_is_safe(rel_path: &Path, entry_type: tar::EntryType) -> Result<()> {
87    if rel_path.is_absolute()
88        || rel_path.components().any(|c| matches!(c, std::path::Component::ParentDir))
89    {
90        bail!("archive entry {rel_path:?} has an unsafe path");
91    }
92    if entry_type.is_symlink() || entry_type.is_hard_link() {
93        bail!("archive entry {rel_path:?} is a symlink/hard link, refusing to extract it");
94    }
95    Ok(())
96}
97
98fn open_archive(archive_path: &Path) -> Result<tar::Archive<GzDecoder<fs::File>>> {
99    let in_file =
100        fs::File::open(archive_path).with_context(|| format!("open archive {archive_path:?}"))?;
101    Ok(tar::Archive::new(GzDecoder::new(in_file)))
102}
103
104/// Extract a `.tar.gz` produced by [`export`] into `target_brain_path`.
105///
106/// An entry whose relative path already exists under `target_brain_path` is
107/// left untouched (and recorded in [`ImportStats::skipped`]) unless `force`
108/// is set, so importing never silently overwrites a teammate's existing
109/// notes. Rebuilding the SQLite index afterwards is the caller's
110/// responsibility (mirrors `ninox brain index`).
111///
112/// Runs in two passes: the first validates every entry via
113/// [`check_entry_is_safe`] without writing anything, so an archive
114/// containing even one unsafe entry (absolute path, `..` traversal, symlink,
115/// hard link) is rejected wholesale instead of leaving a half-imported,
116/// unreported brain on disk from the entries that came before it. The
117/// second pass does the actual extraction; a per-entry failure there (e.g.
118/// a `--force` overwrite of a file by a same-named directory) is recorded in
119/// [`ImportStats::failed`] rather than aborting, so one bad entry still
120/// doesn't lose the rest of an otherwise-good import.
121pub fn import(archive_path: &Path, target_brain_path: &Path, force: bool) -> Result<ImportStats> {
122    fs::create_dir_all(target_brain_path)
123        .with_context(|| format!("create brain dir {target_brain_path:?}"))?;
124
125    let mut validate_archive = open_archive(archive_path)?;
126    for entry in validate_archive.entries().context("read archive entries")? {
127        let entry = entry.context("read archive entry")?;
128        let rel_path = entry.path().context("read entry path")?.to_path_buf();
129        check_entry_is_safe(&rel_path, entry.header().entry_type())?;
130    }
131
132    let mut archive = open_archive(archive_path)?;
133    let mut stats = ImportStats::default();
134    for entry in archive.entries().context("read archive entries")? {
135        let mut entry = entry.context("read archive entry")?;
136        let rel_path = entry.path().context("read entry path")?.to_path_buf();
137        let entry_type = entry.header().entry_type();
138        check_entry_is_safe(&rel_path, entry_type)?;
139
140        if entry_type.is_dir() {
141            // Directories are implicit in the relative file paths we
142            // extract; nothing to do for an explicit directory entry.
143            continue;
144        }
145
146        let dest = target_brain_path.join(&rel_path);
147        if dest.exists() && !force {
148            stats.skipped.push(rel_path);
149            continue;
150        }
151
152        if let Some(parent) = dest.parent() {
153            fs::create_dir_all(parent)
154                .with_context(|| format!("create directory {parent:?}"))?;
155        }
156        // A single bad entry (e.g. `--force` onto a same-named directory)
157        // is recorded rather than aborting the whole import via `?`, so the
158        // rest of an otherwise-good archive still gets imported.
159        match entry.unpack(&dest) {
160            Ok(_) => stats.imported += 1,
161            Err(err) => stats.failed.push((rel_path, err.to_string())),
162        }
163    }
164
165    Ok(stats)
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use tempfile::tempdir;
172
173    #[test]
174    fn export_packages_markdown_and_excludes_index_db() {
175        let brain_dir = tempdir().unwrap();
176        fs::create_dir_all(brain_dir.path().join("repos")).unwrap();
177        fs::write(brain_dir.path().join("repos/ninox.md"), "---\nname: ninox\n---\nBody.").unwrap();
178        fs::create_dir_all(brain_dir.path().join("symbols")).unwrap();
179        fs::write(brain_dir.path().join("symbols/thing.md"), "# Thing").unwrap();
180        // Simulate a derived index sitting alongside the source files.
181        fs::write(brain_dir.path().join(".index.db"), b"not markdown").unwrap();
182        fs::write(brain_dir.path().join(".gitignore"), ".index.db\n").unwrap();
183
184        let archive_dir = tempdir().unwrap();
185        let archive_path = archive_dir.path().join("brain.tar.gz");
186        let stats = export(brain_dir.path(), &archive_path).unwrap();
187        assert_eq!(stats.files, 2);
188        assert!(archive_path.exists());
189
190        let file = fs::File::open(&archive_path).unwrap();
191        let mut archive = tar::Archive::new(GzDecoder::new(file));
192        let names: Vec<String> = archive
193            .entries()
194            .unwrap()
195            .map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned())
196            .collect();
197        assert!(names.contains(&"repos/ninox.md".to_string()));
198        assert!(names.contains(&"symbols/thing.md".to_string()));
199        assert!(
200            !names.iter().any(|n| n.contains(".index.db")),
201            "archive must not contain the derived index: {names:?}"
202        );
203        assert!(
204            !names.iter().any(|n| n == ".gitignore"),
205            "archive should only contain markdown source: {names:?}"
206        );
207    }
208
209    #[test]
210    fn import_extracts_entries_and_rebuild_makes_them_queryable() {
211        let source_dir = tempdir().unwrap();
212        fs::create_dir_all(source_dir.path().join("repos")).unwrap();
213        fs::write(
214            source_dir.path().join("repos/ninox.md"),
215            "---\nname: ninox\ntags:\n- rust\n---\nEntry point is main.rs.",
216        )
217        .unwrap();
218
219        let archive_dir = tempdir().unwrap();
220        let archive_path = archive_dir.path().join("brain.tar.gz");
221        export(source_dir.path(), &archive_path).unwrap();
222
223        let target_dir = tempdir().unwrap();
224        let stats = import(&archive_path, target_dir.path(), false).unwrap();
225        assert_eq!(stats.imported, 1);
226        assert!(stats.skipped.is_empty());
227        assert!(target_dir.path().join("repos/ninox.md").exists());
228
229        let brain = crate::brain::BrainIndex::open(target_dir.path()).unwrap();
230        brain.rebuild(None).unwrap();
231        let results = brain
232            .query("main.rs", None, crate::brain::QueryFilters::default())
233            .unwrap();
234        assert_eq!(results.len(), 1);
235        assert_eq!(results[0].id, "repos/ninox.md");
236    }
237
238    #[test]
239    fn import_skips_conflicting_files_by_default() {
240        let source_dir = tempdir().unwrap();
241        fs::create_dir_all(source_dir.path().join("repos")).unwrap();
242        fs::write(source_dir.path().join("repos/ninox.md"), "incoming version").unwrap();
243        let archive_dir = tempdir().unwrap();
244        let archive_path = archive_dir.path().join("brain.tar.gz");
245        export(source_dir.path(), &archive_path).unwrap();
246
247        let target_dir = tempdir().unwrap();
248        fs::create_dir_all(target_dir.path().join("repos")).unwrap();
249        fs::write(target_dir.path().join("repos/ninox.md"), "teammate's existing notes").unwrap();
250
251        let stats = import(&archive_path, target_dir.path(), false).unwrap();
252        assert_eq!(stats.imported, 0);
253        assert_eq!(stats.skipped, vec![PathBuf::from("repos/ninox.md")]);
254        assert_eq!(
255            fs::read_to_string(target_dir.path().join("repos/ninox.md")).unwrap(),
256            "teammate's existing notes",
257            "conflicting file must not be overwritten without --force"
258        );
259    }
260
261    #[test]
262    fn import_with_force_overwrites_conflicting_files() {
263        let source_dir = tempdir().unwrap();
264        fs::create_dir_all(source_dir.path().join("repos")).unwrap();
265        fs::write(source_dir.path().join("repos/ninox.md"), "incoming version").unwrap();
266        let archive_dir = tempdir().unwrap();
267        let archive_path = archive_dir.path().join("brain.tar.gz");
268        export(source_dir.path(), &archive_path).unwrap();
269
270        let target_dir = tempdir().unwrap();
271        fs::create_dir_all(target_dir.path().join("repos")).unwrap();
272        fs::write(target_dir.path().join("repos/ninox.md"), "teammate's existing notes").unwrap();
273
274        let stats = import(&archive_path, target_dir.path(), true).unwrap();
275        assert_eq!(stats.imported, 1);
276        assert!(stats.skipped.is_empty());
277        assert_eq!(
278            fs::read_to_string(target_dir.path().join("repos/ninox.md")).unwrap(),
279            "incoming version"
280        );
281    }
282
283    #[test]
284    fn import_creates_target_brain_dir_if_missing() {
285        let source_dir = tempdir().unwrap();
286        fs::create_dir_all(source_dir.path().join("concepts")).unwrap();
287        fs::write(source_dir.path().join("concepts/x.md"), "content").unwrap();
288        let archive_dir = tempdir().unwrap();
289        let archive_path = archive_dir.path().join("brain.tar.gz");
290        export(source_dir.path(), &archive_path).unwrap();
291
292        let target_dir = tempdir().unwrap();
293        let target_brain_path = target_dir.path().join("nested/brain");
294        assert!(!target_brain_path.exists());
295
296        let stats = import(&archive_path, &target_brain_path, false).unwrap();
297        assert_eq!(stats.imported, 1);
298        assert!(target_brain_path.join("concepts/x.md").exists());
299    }
300
301    // -----------------------------------------------------------------
302    // Adversarial / malformed archives — `export` never produces any of
303    // these, so they're hand-built with the low-level `tar` API directly.
304    // -----------------------------------------------------------------
305
306    type RawBuilder = tar::Builder<GzEncoder<fs::File>>;
307
308    // Appends an entry by writing the name field's raw bytes directly
309    // rather than through `Header::set_path`, which itself refuses absolute
310    // paths and `..` components — bypassing it here simulates a
311    // hand-crafted malicious archive, which the tar *format* doesn't
312    // forbid even though this crate's own writer does.
313    fn append_raw_entry(
314        builder: &mut RawBuilder,
315        entry_path: &str,
316        entry_type: tar::EntryType,
317        content: &[u8],
318        link_target: Option<&str>,
319    ) {
320        let mut header = tar::Header::new_gnu();
321        {
322            let name_bytes = entry_path.as_bytes();
323            assert!(name_bytes.len() < 100, "test path too long for the name field");
324            header.as_mut_bytes()[..name_bytes.len()].copy_from_slice(name_bytes);
325        }
326        header.set_entry_type(entry_type);
327        header.set_size(content.len() as u64);
328        header.set_mode(0o644);
329        if let Some(target) = link_target {
330            header.set_link_name(target).unwrap();
331        }
332        header.set_cksum();
333        builder.append(&header, content).unwrap();
334    }
335
336    fn write_single_entry_archive(
337        archive_path: &Path,
338        entry_path: &str,
339        entry_type: tar::EntryType,
340        content: &[u8],
341        link_target: Option<&str>,
342    ) {
343        let file = fs::File::create(archive_path).unwrap();
344        let mut builder = tar::Builder::new(GzEncoder::new(file, Compression::default()));
345        append_raw_entry(&mut builder, entry_path, entry_type, content, link_target);
346        let enc = builder.into_inner().unwrap();
347        enc.finish().unwrap();
348    }
349
350    #[test]
351    fn import_rejects_absolute_path_entries() {
352        let archive_dir = tempdir().unwrap();
353        let archive_path = archive_dir.path().join("evil.tar.gz");
354        write_single_entry_archive(
355            &archive_path,
356            "/etc/cron.d/pwn",
357            tar::EntryType::Regular,
358            b"malicious",
359            None,
360        );
361
362        let target_dir = tempdir().unwrap();
363        let err = import(&archive_path, target_dir.path(), false).unwrap_err();
364        assert!(
365            err.to_string().contains("unsafe path"),
366            "expected an unsafe-path error, got: {err}"
367        );
368    }
369
370    #[test]
371    fn import_rejects_parent_dir_escape_entries() {
372        let archive_dir = tempdir().unwrap();
373        let archive_path = archive_dir.path().join("evil.tar.gz");
374        write_single_entry_archive(
375            &archive_path,
376            "../../evil.md",
377            tar::EntryType::Regular,
378            b"malicious",
379            None,
380        );
381
382        let target_dir = tempdir().unwrap();
383        let brain_dir = target_dir.path().join("brain");
384        let err = import(&archive_path, &brain_dir, false).unwrap_err();
385        assert!(
386            err.to_string().contains("unsafe path"),
387            "expected an unsafe-path error, got: {err}"
388        );
389        assert!(
390            !target_dir.path().join("evil.md").exists(),
391            "the entry must never be written outside the target brain dir"
392        );
393    }
394
395    #[test]
396    fn import_rejects_symlink_entries() {
397        let archive_dir = tempdir().unwrap();
398        let archive_path = archive_dir.path().join("evil.tar.gz");
399        write_single_entry_archive(
400            &archive_path,
401            "repos/escape",
402            tar::EntryType::Symlink,
403            b"",
404            Some("/tmp"),
405        );
406
407        let target_dir = tempdir().unwrap();
408        let err = import(&archive_path, target_dir.path(), false).unwrap_err();
409        assert!(
410            err.to_string().contains("symlink"),
411            "expected a symlink-rejection error, got: {err}"
412        );
413        assert!(
414            !target_dir.path().join("repos/escape").exists(),
415            "no symlink should ever be created by import"
416        );
417    }
418
419    #[test]
420    fn import_skips_explicit_directory_entries_without_error() {
421        let archive_dir = tempdir().unwrap();
422        let archive_path = archive_dir.path().join("dirs.tar.gz");
423        write_single_entry_archive(&archive_path, "repos/", tar::EntryType::Directory, b"", None);
424
425        let target_dir = tempdir().unwrap();
426        let stats = import(&archive_path, target_dir.path(), false).unwrap();
427        assert_eq!(stats.imported, 0);
428        assert!(stats.skipped.is_empty());
429        assert!(stats.failed.is_empty());
430    }
431
432    #[test]
433    fn import_rejects_corrupt_archive_without_panicking() {
434        let archive_dir = tempdir().unwrap();
435        let archive_path = archive_dir.path().join("not-a-real-archive.tar.gz");
436        fs::write(&archive_path, b"this is not gzip data at all").unwrap();
437
438        let target_dir = tempdir().unwrap();
439        let result = import(&archive_path, target_dir.path(), false);
440        assert!(result.is_err(), "a corrupt archive must error, not panic");
441    }
442
443    #[test]
444    fn import_records_a_failed_entry_without_losing_the_rest_of_the_import() {
445        let source_dir = tempdir().unwrap();
446        fs::create_dir_all(source_dir.path().join("repos")).unwrap();
447        fs::write(source_dir.path().join("repos/a.md"), "A content").unwrap();
448        fs::write(source_dir.path().join("repos/b.md"), "B content").unwrap();
449        let archive_dir = tempdir().unwrap();
450        let archive_path = archive_dir.path().join("brain.tar.gz");
451        export(source_dir.path(), &archive_path).unwrap();
452
453        let target_dir = tempdir().unwrap();
454        // Force a type mismatch: the target already has a *directory* where
455        // the archive wants to write a *file*, so unpacking that one entry
456        // will fail even with `force` set.
457        fs::create_dir_all(target_dir.path().join("repos/a.md")).unwrap();
458
459        let stats = import(&archive_path, target_dir.path(), true).unwrap();
460        assert_eq!(stats.imported, 1, "repos/b.md should still import: {stats:?}");
461        assert_eq!(stats.failed.len(), 1);
462        assert_eq!(stats.failed[0].0, PathBuf::from("repos/a.md"));
463        assert!(target_dir.path().join("repos/b.md").exists());
464    }
465
466    #[test]
467    fn import_writes_nothing_when_any_entry_fails_the_safety_check() {
468        let archive_dir = tempdir().unwrap();
469        let archive_path = archive_dir.path().join("mixed.tar.gz");
470        {
471            let file = fs::File::create(&archive_path).unwrap();
472            let mut builder = tar::Builder::new(GzEncoder::new(file, Compression::default()));
473            // A perfectly safe entry ordered *before* the unsafe one, so a
474            // naive per-entry bail would already have written it to disk.
475            append_raw_entry(&mut builder, "repos/a.md", tar::EntryType::Regular, b"safe content", None);
476            append_raw_entry(
477                &mut builder,
478                "/etc/cron.d/pwn",
479                tar::EntryType::Regular,
480                b"malicious",
481                None,
482            );
483            let enc = builder.into_inner().unwrap();
484            enc.finish().unwrap();
485        }
486
487        let target_dir = tempdir().unwrap();
488        let err = import(&archive_path, target_dir.path(), false).unwrap_err();
489        assert!(err.to_string().contains("unsafe path"), "unexpected error: {err}");
490        assert!(
491            !target_dir.path().join("repos/a.md").exists(),
492            "no entry should be written when the archive contains any unsafe entry, even one ordered earlier"
493        );
494    }
495
496    #[test]
497    fn export_creates_brain_dir_if_missing_and_produces_empty_archive() {
498        let parent_dir = tempdir().unwrap();
499        let brain_path = parent_dir.path().join("never-indexed-brain");
500        assert!(!brain_path.exists());
501
502        let archive_dir = tempdir().unwrap();
503        let archive_path = archive_dir.path().join("brain.tar.gz");
504        let stats = export(&brain_path, &archive_path).unwrap();
505
506        assert_eq!(stats.files, 0);
507        assert!(
508            brain_path.exists(),
509            "export should create the brain dir on demand, like BrainIndex::open does"
510        );
511        assert!(archive_path.exists());
512    }
513}