240 lines
9.4 KiB
Rust
240 lines
9.4 KiB
Rust
//! RTF text extraction via the `rtf-parser` crate.
|
|
//!
|
|
//! Claims `application/rtf` (what both `mime_guess` and `infer`'s magic
|
|
//! matcher emit) and `text/rtf` (a common alias). Registered *before* the
|
|
//! plaintext extractor in [`super::Registry::default_set`], because
|
|
//! plaintext claims every `text/*` and would otherwise swallow `text/rtf`
|
|
//! and index the control-word noise raw.
|
|
//!
|
|
//! `rtf-parser` resolves to `vendor/rtf-parser`, a patched copy — its lexer
|
|
//! ended a control word at whitespace and nowhere else, which silently dropped
|
|
//! text from documents LibreOffice and Word produce. The `[patch.crates-io]`
|
|
//! note in the workspace manifest is where that is written up; the tests below
|
|
//! and `tests/extraction_corpus.rs` are what keep it fixed.
|
|
|
|
use std::fs::File;
|
|
use std::io::Read;
|
|
use std::path::Path;
|
|
|
|
use rtf_parser::document::RtfDocument;
|
|
|
|
use super::{ExtractError, ExtractedContent, Extractor};
|
|
|
|
/// Ceiling on a single read; see [`super::plaintext`], same reasoning.
|
|
const MAX_READ: usize = 64 * 1024 * 1024;
|
|
|
|
/// Parse a complete RTF file's bytes. Shared by both entry points so
|
|
/// on-disk and already-in-memory extraction cannot drift apart.
|
|
///
|
|
/// RTF is 7-bit ASCII by design — non-ASCII characters travel as `\'hh` and
|
|
/// `\uN` escapes — so a lossy UTF-8 view loses nothing from a well-formed
|
|
/// document, and a malformed one fails in the parser with a real reason
|
|
/// rather than in the decode.
|
|
fn parse(bytes: Vec<u8>, path: &Path) -> Result<ExtractedContent, ExtractError> {
|
|
let source = String::from_utf8_lossy(&bytes);
|
|
match RtfDocument::try_from(source.as_ref()) {
|
|
Ok(doc) => Ok(ExtractedContent::with_text(doc.get_text())),
|
|
Err(e) => Err(format!("rtf parse {}: {}", path.display(), e)),
|
|
}
|
|
}
|
|
|
|
pub struct RtfExtractor;
|
|
|
|
/// Read at most `cap` bytes of `path`.
|
|
///
|
|
/// Bounded rather than `fs::read`: the size gate that admitted this file was
|
|
/// applied to what the walk recorded, and the file may have grown since.
|
|
/// `rtf-parser` also amplifies its input several-fold in heap, so an unbounded
|
|
/// read here is unbounded twice over.
|
|
fn read_capped(path: &Path, cap: u64) -> Result<Vec<u8>, ExtractError> {
|
|
let file = File::open(path).map_err(|e| format!("rtf read {}: {}", path.display(), e))?;
|
|
let mut bytes = Vec::new();
|
|
file.take(cap)
|
|
.read_to_end(&mut bytes)
|
|
.map_err(|e| format!("rtf read {}: {}", path.display(), e))?;
|
|
Ok(bytes)
|
|
}
|
|
|
|
impl Extractor for RtfExtractor {
|
|
fn supports(&self, mime: &str) -> bool {
|
|
mime == "application/rtf" || mime == "text/rtf"
|
|
}
|
|
|
|
fn extract(&self, path: &Path) -> Result<ExtractedContent, ExtractError> {
|
|
parse(read_capped(path, MAX_READ as u64)?, path)
|
|
}
|
|
|
|
/// RTF has no trailer and needs no seeking, so a head that is the whole
|
|
/// file parses exactly like the on-disk path.
|
|
fn extract_from_head(
|
|
&self,
|
|
path: &Path,
|
|
head: &[u8],
|
|
) -> Option<Result<ExtractedContent, ExtractError>> {
|
|
Some(parse(head.to_vec(), path))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn tmp(tag: &str, body: &[u8]) -> std::path::PathBuf {
|
|
let p = crate::testutil::scratch_dir(tag).join("sample.rtf");
|
|
crate::testutil::touch(&p, body);
|
|
p
|
|
}
|
|
|
|
#[test]
|
|
fn extracts_text_without_control_words() {
|
|
let body = br"{\rtf1\ansi Hello {\b World}!}";
|
|
let p = tmp("basic", body);
|
|
let c = RtfExtractor.extract(&p).unwrap();
|
|
assert_eq!(c.text, "Hello World!");
|
|
std::fs::remove_file(&p).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn head_extraction_matches_reading_the_file() {
|
|
// `\'e9` is the RTF hex escape for an e-acute: the literal itself
|
|
// stays 7-bit ASCII while the extracted text does not.
|
|
let body = br"{\rtf1\ansi caf\'e9 at noon}";
|
|
let p = tmp("agree", body);
|
|
let from_disk = RtfExtractor.extract(&p).unwrap();
|
|
let from_head = RtfExtractor.extract_from_head(&p, body).unwrap().unwrap();
|
|
assert_eq!(from_disk.text, from_head.text);
|
|
assert!(from_disk.text.contains("café"), "{:?}", from_disk.text);
|
|
std::fs::remove_file(&p).ok();
|
|
}
|
|
|
|
/// A `\\u` escape naming a lone UTF-16 surrogate costs one character, not
|
|
/// the document and not the thread.
|
|
///
|
|
/// `rtf-parser` reached `String::from_utf16(..).unwrap()` with whatever
|
|
/// `\\uN` supplied and screened nothing for the surrogate range, so a
|
|
/// fifteen-byte document could panic. RTF is one of the two extractors that
|
|
/// also run at *walk* time, off `extract_from_head`, where a panicking
|
|
/// worker costs the root its entire content pass and disables stale
|
|
/// cleanup run-wide — so the panic was contained in `decide_content` and
|
|
/// `prepare_file_record`, and the file recorded as FAILED.
|
|
///
|
|
/// `vendor/rtf-parser` decodes lossily instead (LOCAL PATCH, see
|
|
/// `Parser::flush_unicode`), which beats either outcome: the bad escape
|
|
/// becomes one `U+FFFD` and the rest of the document is indexed. Both
|
|
/// entry points are still exercised, because the containment above them
|
|
/// has to keep working for every other way a parser can panic.
|
|
#[test]
|
|
fn a_lone_surrogate_escape_costs_one_character() {
|
|
// `\u55296` is a high surrogate with no low half to follow it. The `?`
|
|
// is its ANSI fallback, written the way a real producer writes one —
|
|
// spelled with a space delimiter instead, the `a` of `after` would be
|
|
// the fallback and would correctly be eaten.
|
|
let body = "{\\rtf1\\ansi before \\u55296?after}".as_bytes();
|
|
let p = tmp("surrogate", body);
|
|
|
|
// The on-disk path, as the content pass reaches it.
|
|
let outcome = crate::file_handling::decide_content(
|
|
p.to_str().unwrap(),
|
|
Some("application/rtf"),
|
|
&crate::extract::Registry::default_set(),
|
|
&crate::config::Config::default(),
|
|
);
|
|
let text = match &outcome {
|
|
crate::file_handling::ContentOutcome::Done { text } => text.clone(),
|
|
other => panic!("a malformed escape must not fail the document: {other:?}"),
|
|
};
|
|
assert!(
|
|
text.contains("before") && text.contains("after"),
|
|
"the rest of the document must survive: {text:?}"
|
|
);
|
|
assert!(
|
|
text.contains('\u{FFFD}'),
|
|
"the bad escape must leave a replacement character: {text:?}"
|
|
);
|
|
|
|
// And the head path, as a walk worker reaches it: through the registry,
|
|
// which is where the containment for any *other* panicking input lives.
|
|
let head = crate::extract::Registry::default_set().extract_complete_head(
|
|
&p,
|
|
"application/rtf",
|
|
body,
|
|
);
|
|
assert_eq!(
|
|
head.expect("claimed").expect("parsed").text,
|
|
text,
|
|
"head and disk extraction must agree"
|
|
);
|
|
|
|
std::fs::remove_file(&p).ok();
|
|
}
|
|
|
|
/// `\\par` ends a paragraph, so it has to reach the text as a line break.
|
|
///
|
|
/// It used to emit nothing, and every paragraph boundary closed up:
|
|
/// a LibreOffice document came back as `...do eiusmod.The needle...`.
|
|
/// No text was lost, but the join invents word and sentence boundaries
|
|
/// that are not in the document — which a snippet then shows to the user,
|
|
/// and which a phrase query can match across. Fixed in
|
|
/// `vendor/rtf-parser` (LOCAL PATCH), alongside `\\line`, which always
|
|
/// did the right thing.
|
|
#[test]
|
|
fn paragraph_breaks_reach_the_text() {
|
|
let body = br"{\rtf1\ansi First paragraph.\par Second paragraph.\par}";
|
|
let p = tmp("par", body);
|
|
let text = RtfExtractor.extract(&p).unwrap().text;
|
|
assert!(
|
|
text.contains("First paragraph.\nSecond paragraph."),
|
|
"paragraphs ran together: {text:?}"
|
|
);
|
|
std::fs::remove_file(&p).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_input_errors_and_names_the_file() {
|
|
let p = tmp("broken", br"{\rtf1 truncated");
|
|
let err = RtfExtractor.extract(&p).unwrap_err();
|
|
// The path itself, not a fixed prefix: this is the message a user sees
|
|
// in `list-failed`, and it is useless without naming the file.
|
|
assert!(
|
|
err.contains(&p.display().to_string()),
|
|
"must name the file: {}",
|
|
err
|
|
);
|
|
std::fs::remove_file(&p).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn supports_rtf_mimes_only() {
|
|
let e = RtfExtractor;
|
|
assert!(e.supports("application/rtf"));
|
|
assert!(e.supports("text/rtf"));
|
|
assert!(!e.supports("text/plain"));
|
|
assert!(!e.supports("application/pdf"));
|
|
}
|
|
|
|
/// `rtf-parser` amplifies its input several-fold in heap, so the read that
|
|
/// feeds it has to be bounded independently of what the walk recorded.
|
|
#[test]
|
|
fn a_read_stops_at_the_cap() {
|
|
let body = vec![b'x'; 4096];
|
|
let p = tmp("cap", &body);
|
|
assert_eq!(
|
|
read_capped(&p, 100).unwrap().len(),
|
|
100,
|
|
"read past the cap"
|
|
);
|
|
assert_eq!(
|
|
read_capped(&p, MAX_READ as u64).unwrap().len(),
|
|
4096,
|
|
"a file under the cap must be read whole"
|
|
);
|
|
std::fs::remove_file(&p).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_file_is_an_error_naming_it() {
|
|
let p = crate::testutil::scratch_dir("rtf-missing").join("nope.rtf");
|
|
let err = read_capped(&p, MAX_READ as u64).unwrap_err();
|
|
assert!(err.contains(&p.display().to_string()), "{err}");
|
|
}
|
|
}
|