Skip to main content

cortex/
importer.rs

1// Copyright 2015-2025 Deyan Ginev. See the LICENSE
2// file at the top-level directory of this distribution.
3//
4// Licensed under the MIT license <LICENSE-MIT or http://opensource.org/licenses/MIT>.
5// This file may not be copied, modified, or distributed
6// except according to those terms.
7
8//! Import a new corpus into the framework
9use crate::backend::Backend;
10use crate::helpers::TaskStatus;
11use crate::models::{Corpus, NewTask};
12use glob::glob;
13use std::collections::HashSet;
14use std::env;
15use std::error::Error;
16use std::fs;
17use std::fs::File;
18use std::io::{Read, Write};
19use std::path::Path;
20use std::path::PathBuf;
21
22/// Struct for performing corpus imports into `CorTeX`
23#[derive(Debug)]
24pub struct Importer {
25  /// a `Corpus` to be imported, containing all relevant metadata
26  pub corpus: Corpus,
27  /// a `Backend` on which to persist the import into the Task store
28  pub backend: Backend,
29  /// the current working directory, to resolve relative paths
30  pub cwd: PathBuf,
31  /// the known prefixes of top-level directories to import
32  /// used to avoid re-examining existing directories.
33  pub active_prefixes: HashSet<String>,
34}
35impl Default for Importer {
36  fn default() -> Importer {
37    let default_backend = Backend::default();
38    // We'll add a mock corpus to the Importer default but it is
39    // *NOT* meant to be used in any real operations, as the Corpus isn't
40    // actually registered in the DB.
41    Importer {
42      corpus: Corpus {
43        name: "mock corpus".to_string(),
44        id: 0,
45        path: ".".to_string(),
46        complex: true,
47        description: String::new(),
48        parent_corpus_id: None,
49        selection: None,
50        // A nil sentinel: this mock corpus is never registered in the DB, so it carries no real
51        // external handle (a registered corpus gets its UUIDv7 from the column default).
52        public_id: uuid::Uuid::nil(),
53      },
54      backend: default_backend,
55      cwd: Importer::cwd(),
56      active_prefixes: HashSet::new(),
57    }
58  }
59}
60
61impl Importer {
62  /// Convenience method for (recklessly?) obtaining the current working dir
63  pub fn cwd() -> PathBuf { env::current_dir().unwrap() }
64  /// Top-level method for unpacking an arxiv-toplogy corpus from its tar-ed form
65  fn unpack(&mut self) -> Result<(), Box<dyn Error>> {
66    self.unpack_arxiv_top()?;
67    self.unpack_arxiv_months()?;
68    Ok(())
69  }
70  fn unpack_extend(&mut self) -> Result<(), Box<dyn Error>> {
71    self.unpack_extend_arxiv_top()?;
72    // We can reuse the monthly unpack, as it deletes all unpacked document archives
73    // In other words, it always acts as a conservative extension
74    self.unpack_arxiv_months()?;
75    Ok(())
76  }
77
78  /// Unpack the top-level tar files from an arxiv-topology corpus
79  fn unpack_arxiv_top(&mut self) -> Result<(), Box<dyn Error>> {
80    let path_str = self.corpus.path.clone();
81    println!("-- Starting top-level unpack at {path_str}");
82    // A corpus path containing glob metacharacters (`[`, `{`, …) makes `glob` return a
83    // `PatternError`; propagate it as a clean import failure rather than `.unwrap()`-panicking.
84    for entry in glob(&(path_str.clone() + "/*.tar"))? {
85      match entry {
86        Ok(path) => match unpack_top_tar(&path, &path_str, false) {
87          Ok(prefixes) => self.active_prefixes.extend(prefixes),
88          Err(reason) => eprintln!("-- import: skipping tar {path:?}: {reason}"),
89        },
90        Err(e) => println!("Failed tar glob: {e:?}"),
91      }
92    }
93    Ok(())
94  }
95
96  /// Top-level extension unpacking for arxiv-topology corpora
97  fn unpack_extend_arxiv_top(&mut self) -> Result<(), Box<dyn Error>> {
98    let mut path_str = self.corpus.path.clone();
99    if !path_str.ends_with('/') {
100      path_str.push('/');
101    }
102    println!("-- Starting top-level unpack-extend at {path_str}");
103    for entry in glob(&(path_str.clone() + "/*.tar"))? {
104      match entry {
105        Ok(path) => match unpack_top_tar(&path, &path_str, true) {
106          Ok(prefixes) => self.active_prefixes.extend(prefixes),
107          Err(reason) => eprintln!("-- import: skipping tar {path:?}: {reason}"),
108        },
109        Err(e) => println!("Failed tar glob: {e:?}"),
110      }
111    }
112    Ok(())
113  }
114
115  /// Unpack the monthly sub-archives of an arxiv-topology corpus, into the CorTeX organization
116  fn unpack_arxiv_months(&self) -> Result<(), Box<dyn Error>> {
117    println!("-- Starting to unpack monthly .gz archives");
118    let path_str = self.corpus.path.clone();
119    let gzs_paths = if self.active_prefixes.is_empty() {
120      vec![path_str + "/*/*.gz"]
121    } else {
122      self
123        .active_prefixes
124        .iter()
125        .map(|ap| format!("{path_str}/{ap}/*.gz"))
126        .collect()
127    };
128    // Skip (with a log) any glob pattern that fails to compile rather than `.unwrap()`-panicking
129    // the whole unpack; the valid patterns still contribute their entries.
130    let globs_iter = gzs_paths
131      .iter()
132      .filter_map(|pattern| match glob(pattern) {
133        Ok(paths) => Some(paths),
134        Err(e) => {
135          eprintln!("-- import: skipping invalid glob pattern {pattern:?}: {e}");
136          None
137        },
138      })
139      .flatten();
140    for entry in globs_iter {
141      match entry {
142        Ok(path) => {
143          if let Err(reason) = unpack_one_gz(&path) {
144            eprintln!("-- import: skipping .gz {path:?}: {reason}");
145          }
146        },
147        Err(e) => println!("Failed gz glob: {e:?}"),
148      }
149    }
150    Ok(())
151  }
152  /// Given a CorTeX-topology corpus, walk the file system and import it into the Task store
153  pub fn walk_import(&mut self) -> Result<usize, Box<dyn Error>> {
154    // Depth bound on the directory walk. `fs::metadata` follows symlinks, so a corpus containing a
155    // symlink loop (malicious, or an accidental backup/snapshot link) would otherwise recurse
156    // forever — unbounded paths → unbounded tasks + a job that keeps "progressing", so the
157    // heartbeat-keyed stale-reap never fires. arXiv-topology corpora are ~3 levels deep, so 64
158    // bounds any loop without truncating a real corpus (DESIGN_PRINCIPLES: no unbounded per-event
159    // resource acquisition on hostile data).
160    const MAX_WALK_DEPTH: usize = 64;
161    let import_extension = if self.corpus.complex { "zip" } else { "tex" };
162    let mut walk_q: Vec<(PathBuf, usize)> = vec![(Path::new(&self.corpus.path).to_owned(), 0)];
163    println!("-- Starting import walk at {}", self.corpus.path);
164    let mut import_q: Vec<NewTask> = Vec::new();
165    let mut import_counter = 0;
166    while let Some((current_path, depth)) = walk_q.pop() {
167      if depth > MAX_WALK_DEPTH {
168        eprintln!(
169          "-- import: skipping {current_path:?} beyond max walk depth {MAX_WALK_DEPTH} \
170           (possible symlink loop)"
171        );
172        continue;
173      }
174      // arXiv data is hostile: one unreadable path (a broken symlink, a vanished or
175      // permission-denied entry) or a non-UTF-8 name must **skip**, not abort the whole import —
176      // blast-radius isolation + transparent (logged) failure (docs/DESIGN_PRINCIPLES.md). Only a
177      // backend write error is fatal (the DB is essential), and it now propagates as a `Result`
178      // rather than `.unwrap()`-panicking.
179      let current_metadata = match fs::metadata(&current_path) {
180        Ok(meta) => meta,
181        Err(e) => {
182          eprintln!("-- import: skipping unreadable path {current_path:?}: {e}");
183          continue;
184        },
185      };
186      if !current_metadata.is_dir() {
187        continue;
188      }
189      let current_path_str = match current_path.to_str() {
190        Some(path_str) => path_str.to_string(),
191        None => {
192          eprintln!("-- import: skipping non-UTF-8 path {current_path:?}");
193          continue;
194        },
195      };
196      let rel_path = current_path_str.replace(&self.corpus.path, "");
197      let mut slash_iter = rel_path.split('/');
198      if rel_path.starts_with('/') {
199        // drop the corpus root piece.
200        slash_iter.next();
201      }
202      if let Some(base) = slash_iter.next() {
203        // if we have an "active_prefixes" filter, comply with it
204        if !base.is_empty()
205          && !self.active_prefixes.is_empty()
206          && !self.active_prefixes.contains(base)
207        {
208          continue;
209        }
210      }
211      // First, test if we just found an entry:
212      let current_entry = match current_path.file_name().and_then(|name| name.to_str()) {
213        Some(local_dir) => local_dir.to_string() + "." + import_extension,
214        None => {
215          eprintln!("-- import: skipping path with no usable file name {current_path:?}");
216          continue;
217        },
218      };
219      let current_entry_path = current_path_str + "/" + &current_entry;
220      match fs::metadata(&current_entry_path) {
221        Ok(_) => {
222          // Found the expected file, import this entry:
223          println!("Found entry: {current_entry_path:?}");
224          import_counter += 1;
225          import_q.push(self.new_task(&current_entry_path));
226          if import_q.len() >= 1000 {
227            // Flush the import queue to backend:
228            println!("Checkpoint backend writer: job {import_counter:?}");
229            self.backend.mark_imported(&import_q)?;
230            import_q.clear();
231          }
232        },
233        Err(_) => {
234          // No such entry found, traverse into the directory — skipping (not aborting) an
235          // unreadable directory or directory entry.
236          match fs::read_dir(&current_path) {
237            Ok(entries) => {
238              for subentry in entries {
239                match subentry {
240                  Ok(subentry) => walk_q.push((subentry.path(), depth + 1)),
241                  Err(e) => {
242                    eprintln!("-- import: skipping unreadable entry under {current_path:?}: {e}")
243                  },
244                }
245              }
246            },
247            Err(e) => eprintln!("-- import: skipping unreadable directory {current_path:?}: {e}"),
248          }
249        },
250      }
251    }
252    if !import_q.is_empty() {
253      println!("Checkpoint backend writer: job {:?}", import_q.len());
254      self.backend.mark_imported(&import_q)?;
255    }
256    println!("--- Imported {import_counter:?} entries.");
257    Ok(import_counter)
258  }
259
260  /// Create a new TODO task for the "import" service and the Importer-specified corpus
261  /// (should get marked as "NoProblem" once the extension is completed)
262  pub fn new_task(&self, entry: &str) -> NewTask {
263    let abs_entry: String = if Path::new(&entry).is_relative() {
264      let mut new_abs = self.cwd.clone();
265      new_abs.push(entry);
266      new_abs.to_str().unwrap().to_string()
267    } else {
268      entry.to_string()
269    };
270
271    NewTask {
272      entry: abs_entry,
273      status: TaskStatus::TODO.raw(),
274      corpus_id: self.corpus.id,
275      service_id: 2,
276    }
277  }
278  /// Top-level import driver, performs an optional unpack, and then an import into the Task store
279  pub fn process(&mut self) -> Result<(), Box<dyn Error>> {
280    // println!("Greetings from the import processor");
281    if self.corpus.complex {
282      // Complex setup has an unpack step:
283      self.unpack()?;
284    }
285    // Walk the directory tree and import the files in the Task store:
286    self.walk_import()?;
287
288    Ok(())
289  }
290
291  /// Top-level corpus extension, performs a check for newly added documents and extracts+adds
292  /// them to the existing corpus tasks
293  pub fn extend_corpus(&mut self) -> Result<(), Box<dyn Error>> {
294    if self.corpus.complex {
295      // Complex setup has an unpack step:
296      self.unpack_extend()?;
297    }
298    // Before we import, mark any current runs as completed.
299    for service in self
300      .corpus
301      .select_services(&mut self.backend.connection)
302      .unwrap_or_default()
303      .iter()
304    {
305      self.backend.mark_new_run(
306        &self.corpus,
307        service,
308        "cli-admin".to_string(), // command line interface only?
309        "extending corpus with more entries".to_string(),
310      )?;
311    }
312    // Use the regular walk_import, at the cost of more database work,
313    // the "Backend::mark_imported" ORM method allows us to insert only if new
314    self.walk_import()?;
315    Ok(())
316  }
317}
318
319/// Transfer the data contained within `Reader` to a `Writer`, assuming it was a single file
320/// Extracts the entries of one top-level arXiv `.tar` to disk (skipping `.pdf`s, and entries
321/// already unpacked), returning the set of top-level prefixes seen. Hostile-data tolerant (I-1): a
322/// bad entry is logged + skipped, never a panic. `extend` additionally skips an entry whose
323/// unpacked directory (the entry name without its `.gz` suffix) already exists.
324fn unpack_top_tar(
325  tar_path: &Path,
326  path_str: &str,
327  extend: bool,
328) -> Result<HashSet<String>, String> {
329  let file = File::open(tar_path).map_err(|e| format!("open: {e}"))?;
330  let mut archive = tar::Archive::new(file);
331  let mut prefixes = HashSet::new();
332  for entry in archive.entries().map_err(|e| format!("tar entries: {e}"))? {
333    let mut entry = match entry {
334      Ok(entry) => entry,
335      Err(e) => {
336        eprintln!("-- import: skipping unreadable tar entry in {tar_path:?}: {e}");
337        continue;
338      },
339    };
340    let entry_pathname = match entry.path() {
341      Ok(path) => path.to_string_lossy().into_owned(),
342      Err(_) => continue,
343    };
344    if entry_pathname.ends_with(".pdf") {
345      continue;
346    }
347    if let Some(base) = entry_pathname.split('/').next() {
348      prefixes.insert(base.to_owned());
349    }
350    let full_extract_path = format!("{path_str}{entry_pathname}");
351    if fs::metadata(&full_extract_path).is_ok() {
352      continue; // already unpacked
353    }
354    if extend {
355      let dir_extract_path = full_extract_path
356        .strip_suffix(".gz")
357        .unwrap_or(&full_extract_path);
358      if dir_extract_path != full_extract_path && fs::metadata(dir_extract_path).is_ok() {
359        continue;
360      }
361    }
362    if let Some(parent) = Path::new(&full_extract_path).parent() {
363      let _ = fs::create_dir_all(parent);
364    }
365    if let Err(e) = entry.unpack(&full_extract_path) {
366      eprintln!("-- import: failed to extract {full_extract_path:?}: {e}");
367    }
368  }
369  Ok(prefixes)
370}
371
372/// Repacks one arXiv per-paper `.gz` source into a `<base>.zip` under `<dir>/<base>/`, then removes
373/// the `.gz`. The decompressed content is **content-detected** (filenames lie): a `tar` becomes its
374/// file entries; headerless content (the arXiv "surprise" — a plain gzipped `.tex`) becomes
375/// `<base>.tex`; any other detected type (e.g. a raw PDF) is **rejected** (the `.gz` kept for
376/// inspection). Hostile-data tolerant (I-1): returns `Err` rather than panicking, so the caller
377/// logs
378/// + skips this one and continues the import.
379fn unpack_one_gz(path: &Path) -> Result<(), String> {
380  let entry_path = path
381    .to_str()
382    .ok_or_else(|| format!("non-UTF8 path {path:?}"))?;
383  let entry_dir = path
384    .parent()
385    .and_then(|p| p.to_str())
386    .ok_or_else(|| format!("no parent dir for {entry_path}"))?;
387  let base_name = path
388    .file_stem()
389    .and_then(|s| s.to_str())
390    .ok_or_else(|| format!("no file stem for {entry_path}"))?;
391  let entry_cp_dir = format!("{entry_dir}/{base_name}");
392  if let Err(reason) = fs::create_dir_all(&entry_cp_dir) {
393    println!("Failed to mkdir -p {entry_cp_dir:?}: {:?}", reason.kind());
394  }
395  let full_extract_path = format!("{entry_cp_dir}/{base_name}.zip");
396
397  // Decompress fully (arXiv per-paper source archives are small).
398  let decompressed = {
399    let file = File::open(entry_path).map_err(|e| format!("open {entry_path}: {e}"))?;
400    let mut decoder = flate2::read::GzDecoder::new(file);
401    let mut buf = Vec::new();
402    decoder
403      .read_to_end(&mut buf)
404      .map_err(|e| format!("gunzip {entry_path}: {e}"))?;
405    buf
406  };
407
408  // Reject mislabeled non-source content *before* writing anything (no stray empty zip left
409  // behind).
410  let detected = infer::get(&decompressed).map(|t| t.extension());
411  if let Some(other) = detected
412    && other != "tar"
413  {
414    return Err(format!(
415      "decompressed content is `{other}`, not a TeX source — rejected"
416    ));
417  }
418
419  let out =
420    File::create(&full_extract_path).map_err(|e| format!("create {full_extract_path}: {e}"))?;
421  let mut zw = zip::ZipWriter::new(out);
422  let opts: zip::write::FileOptions<()> =
423    zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
424  if detected == Some("tar") {
425    let mut tar = tar::Archive::new(std::io::Cursor::new(decompressed.as_slice()));
426    for tar_entry in tar.entries().map_err(|e| format!("tar entries: {e}"))? {
427      let mut tar_entry = match tar_entry {
428        Ok(entry) => entry,
429        Err(e) => {
430          eprintln!("-- import: skipping unreadable tar entry in {entry_path}: {e}");
431          continue;
432        },
433      };
434      if !tar_entry.header().entry_type().is_file() {
435        continue;
436      }
437      let name = match tar_entry.path() {
438        Ok(path) => path.to_string_lossy().into_owned(),
439        Err(_) => continue,
440      };
441      let mut data = Vec::new();
442      if let Err(e) = tar_entry.read_to_end(&mut data) {
443        eprintln!("-- import: reading tar entry {name} failed: {e}");
444        continue;
445      }
446      zw.start_file(&name, opts)
447        .map_err(|e| format!("zip start_file {name}: {e}"))?;
448      zw.write_all(&data)
449        .map_err(|e| format!("zip write {name}: {e}"))?;
450    }
451  } else {
452    // Headerless = the arXiv "surprise": a plain gzipped TeX file.
453    let tex_target = format!("{base_name}.tex");
454    zw.start_file(&tex_target, opts)
455      .map_err(|e| format!("zip start_file {tex_target}: {e}"))?;
456    zw.write_all(&decompressed)
457      .map_err(|e| format!("zip write {tex_target}: {e}"))?;
458  }
459  zw.finish()
460    .map_err(|e| format!("finalize zip {full_extract_path}: {e}"))?;
461
462  if let Err(e) = fs::remove_file(path) {
463    println!("Can't remove source .gz: {e:?}");
464  }
465  Ok(())
466}
467
468#[cfg(test)]
469mod tests {
470  use super::*;
471
472  fn write_gz(path: &Path, content: &[u8]) {
473    let mut enc =
474      flate2::write::GzEncoder::new(File::create(path).unwrap(), flate2::Compression::default());
475    enc.write_all(content).unwrap();
476    enc.finish().unwrap();
477  }
478
479  fn zip_names(zip_path: &Path) -> Vec<String> {
480    let mut archive = zip::ZipArchive::new(File::open(zip_path).unwrap()).unwrap();
481    (0..archive.len())
482      .map(|i| archive.by_index(i).unwrap().name().to_string())
483      .collect()
484  }
485
486  #[test]
487  fn unpack_one_gz_handles_targz_plaintex_and_rejects_wrong_content() {
488    let prefix_dir =
489      std::env::temp_dir().join(format!("cortex_importer_test_{}/0001", std::process::id()));
490    fs::create_dir_all(&prefix_dir).unwrap();
491
492    // (a) a tar.gz with two source files -> a .zip carrying both.
493    let tar_bytes = {
494      let mut builder = tar::Builder::new(Vec::new());
495      for (name, body) in [
496        ("paper.tex", b"\\documentclass{article}".as_ref()),
497        ("fig.eps", b"%!PS-Adobe".as_ref()),
498      ] {
499        let mut header = tar::Header::new_gnu();
500        header.set_size(body.len() as u64);
501        header.set_cksum();
502        builder.append_data(&mut header, name, body).unwrap();
503      }
504      builder.into_inner().unwrap()
505    };
506    let targz = prefix_dir.join("paperA.gz");
507    write_gz(&targz, &tar_bytes);
508    unpack_one_gz(&targz).expect("a tar.gz unpacks");
509    let names = zip_names(&prefix_dir.join("paperA/paperA.zip"));
510    assert!(
511      names.contains(&"paper.tex".to_string()) && names.contains(&"fig.eps".to_string()),
512      "tar.gz -> zip with both files, got {names:?}"
513    );
514    assert!(!targz.exists(), "the source .gz is removed");
515
516    // (b) a plain gzipped .tex (the arXiv "surprise") -> a .zip carrying <base>.tex.
517    let plaintex = prefix_dir.join("paperB.gz");
518    write_gz(
519      &plaintex,
520      b"\\documentclass{article}\\begin{document}hi\\end{document}",
521    );
522    unpack_one_gz(&plaintex).expect("a plain gzipped tex unpacks");
523    assert_eq!(
524      zip_names(&prefix_dir.join("paperB/paperB.zip")),
525      vec!["paperB.tex".to_string()],
526      "a plain gz -> <base>.tex"
527    );
528
529    // (c) a gzipped PDF (wrong content) is rejected; the .gz is kept for inspection.
530    let pdfgz = prefix_dir.join("paperC.gz");
531    write_gz(&pdfgz, b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\nrest of a pdf");
532    assert!(unpack_one_gz(&pdfgz).is_err(), "a gzipped PDF is rejected");
533    assert!(pdfgz.exists(), "the rejected .gz is kept");
534
535    fs::remove_dir_all(
536      std::env::temp_dir().join(format!("cortex_importer_test_{}", std::process::id())),
537    )
538    .ok();
539  }
540}