1use 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#[derive(Debug)]
24pub struct Importer {
25 pub corpus: Corpus,
27 pub backend: Backend,
29 pub cwd: PathBuf,
31 pub active_prefixes: HashSet<String>,
34}
35impl Default for Importer {
36 fn default() -> Importer {
37 let default_backend = Backend::default();
38 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 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 pub fn cwd() -> PathBuf { env::current_dir().unwrap() }
64 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 self.unpack_arxiv_months()?;
75 Ok(())
76 }
77
78 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 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 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 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 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 pub fn walk_import(&mut self) -> Result<usize, Box<dyn Error>> {
154 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 let current_metadata = match fs::metadata(¤t_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 slash_iter.next();
201 }
202 if let Some(base) = slash_iter.next() {
203 if !base.is_empty()
205 && !self.active_prefixes.is_empty()
206 && !self.active_prefixes.contains(base)
207 {
208 continue;
209 }
210 }
211 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 + "/" + ¤t_entry;
220 match fs::metadata(¤t_entry_path) {
221 Ok(_) => {
222 println!("Found entry: {current_entry_path:?}");
224 import_counter += 1;
225 import_q.push(self.new_task(¤t_entry_path));
226 if import_q.len() >= 1000 {
227 println!("Checkpoint backend writer: job {import_counter:?}");
229 self.backend.mark_imported(&import_q)?;
230 import_q.clear();
231 }
232 },
233 Err(_) => {
234 match fs::read_dir(¤t_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 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 pub fn process(&mut self) -> Result<(), Box<dyn Error>> {
280 if self.corpus.complex {
282 self.unpack()?;
284 }
285 self.walk_import()?;
287
288 Ok(())
289 }
290
291 pub fn extend_corpus(&mut self) -> Result<(), Box<dyn Error>> {
294 if self.corpus.complex {
295 self.unpack_extend()?;
297 }
298 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(), "extending corpus with more entries".to_string(),
310 )?;
311 }
312 self.walk_import()?;
315 Ok(())
316 }
317}
318
319fn 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; }
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
372fn 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 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 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 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 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 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 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}