1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub struct ExportStats {
19 pub files: usize,
21}
22
23#[derive(Debug, Clone, Default)]
25pub struct ImportStats {
26 pub imported: usize,
28 pub skipped: Vec<PathBuf>,
31 pub failed: Vec<(PathBuf, String)>,
36}
37
38pub 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 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
80fn 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
104pub 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 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 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 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 type RawBuilder = tar::Builder<GzEncoder<fs::File>>;
307
308 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 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 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}