Merge origin/main (Tantivy content search) into delta-sync branch

Both sides added a parameter to create_application_services and a
setup step before it: this branch's storage-usage/quota service (for
the instant-upload path) and main's Tantivy content index (for
SearchService). The resolution keeps both — the signature takes both
arguments and the build runs storage usage as step 3c and the content
index as 3d.

https://claude.ai/code/session_01WdNenpnujNR2sc32XVvwfS
This commit is contained in:
Claude
2026-06-11 18:32:27 +00:00
19 changed files with 2807 additions and 68 deletions
@@ -53,6 +53,93 @@ type FileRow = (
Option<Uuid>,
);
/// Append the optional type/date/size filters from `criteria` to
/// `conditions`, continuing placeholder numbering from `bind_idx`. Returns
/// the last placeholder index used. The name filter is NOT handled here —
/// it is search-flavour specific (ILIKE for name search, absent for
/// content-hit hydration). Mirror of [`bind_criteria_filters`]; the two
/// must stay in sync.
fn push_criteria_filters(
conditions: &mut Vec<String>,
mut bind_idx: u32,
criteria: &SearchCriteriaDto,
) -> u32 {
if let Some(types) = &criteria.file_types
&& !types.is_empty()
{
bind_idx += 1;
conditions.push(format!(
"LOWER(SUBSTRING(fi.name FROM '\\.([^.]+)$')) = ANY(${bind_idx})"
));
}
if criteria.created_after.is_some() {
bind_idx += 1;
conditions.push(format!(
"EXTRACT(EPOCH FROM fi.created_at)::bigint >= ${bind_idx}"
));
}
if criteria.created_before.is_some() {
bind_idx += 1;
conditions.push(format!(
"EXTRACT(EPOCH FROM fi.created_at)::bigint <= ${bind_idx}"
));
}
if criteria.modified_after.is_some() {
bind_idx += 1;
conditions.push(format!(
"EXTRACT(EPOCH FROM fi.updated_at)::bigint >= ${bind_idx}"
));
}
if criteria.modified_before.is_some() {
bind_idx += 1;
conditions.push(format!(
"EXTRACT(EPOCH FROM fi.updated_at)::bigint <= ${bind_idx}"
));
}
if criteria.min_size.is_some() {
bind_idx += 1;
conditions.push(format!("fi.size >= ${bind_idx}"));
}
if criteria.max_size.is_some() {
bind_idx += 1;
conditions.push(format!("fi.size <= ${bind_idx}"));
}
bind_idx
}
/// Bind the values for the filters appended by [`push_criteria_filters`],
/// in the same order.
fn bind_criteria_filters<'q, O>(
mut query: sqlx::query::QueryAs<'q, sqlx::Postgres, O, sqlx::postgres::PgArguments>,
criteria: &SearchCriteriaDto,
) -> sqlx::query::QueryAs<'q, sqlx::Postgres, O, sqlx::postgres::PgArguments> {
if let Some(types) = &criteria.file_types
&& !types.is_empty()
{
let lower_types: Vec<String> = types.iter().map(|t| t.to_lowercase()).collect();
query = query.bind(lower_types);
}
if let Some(v) = criteria.created_after {
query = query.bind(v as i64);
}
if let Some(v) = criteria.created_before {
query = query.bind(v as i64);
}
if let Some(v) = criteria.modified_after {
query = query.bind(v as i64);
}
if let Some(v) = criteria.modified_before {
query = query.bind(v as i64);
}
if let Some(v) = criteria.min_size {
query = query.bind(v as i64);
}
if let Some(v) = criteria.max_size {
query = query.bind(v as i64);
}
query
}
/// File read repository backed by PostgreSQL metadata + blob storage.
pub struct FileBlobReadRepository {
pool: Arc<PgPool>,
@@ -94,6 +181,80 @@ impl FileBlobReadRepository {
self.hash_cache.clone()
}
/// Hydrate content-index candidate ids into `File`s, re-applying the
/// caller's scope and the active search filters (owner, trash state,
/// folder scope, types, dates, sizes). The NAME filter is deliberately
/// NOT applied — content hits don't need to match it. Ids that fail any
/// filter (or no longer exist — the index is eventually consistent)
/// simply drop out, so a stale index can never leak a result.
pub async fn fetch_files_by_ids_filtered(
&self,
ids: &[String],
criteria: &SearchCriteriaDto,
user_id: Uuid,
) -> Result<Vec<File>, DomainError> {
// Index hits are externally produced strings — parse defensively.
let uuid_ids: Vec<Uuid> = ids.iter().filter_map(|id| id.parse().ok()).collect();
if uuid_ids.is_empty() {
return Ok(Vec::new());
}
let mut conditions: Vec<String> = vec![
"fi.id = ANY($1)".to_string(),
"fi.user_id = $2".to_string(),
"fi.is_trashed = false".to_string(),
];
let mut bind_idx = 2u32;
if criteria.folder_id.is_some() {
bind_idx += 1;
if criteria.recursive {
conditions.push(format!(
"fo.lpath <@ (SELECT lpath FROM storage.folders WHERE id = ${bind_idx}::uuid)"
));
} else {
conditions.push(format!("fi.folder_id = ${bind_idx}::uuid"));
}
}
push_criteria_filters(&mut conditions, bind_idx, criteria);
let where_clause = conditions.join(" AND ");
let sql = format!(
"SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, \
fi.size, fi.mime_type, \
EXTRACT(EPOCH FROM fi.created_at)::bigint, \
EXTRACT(EPOCH FROM fi.updated_at)::bigint, \
fi.blob_hash, \
fi.user_id \
FROM storage.files fi \
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id \
WHERE {where_clause}"
);
let mut query = sqlx::query_as::<_, FileRow>(&sql)
.bind(uuid_ids)
.bind(user_id);
if let Some(folder_id) = criteria.folder_id.as_deref() {
query = query.bind(folder_id);
}
query = bind_criteria_filters(query, criteria);
let rows = query.fetch_all(self.pool.as_ref()).await.map_err(|e| {
DomainError::internal_error("FileBlobRead", format!("hydrate by ids: {e}"))
})?;
rows.into_iter()
.map(
|(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| {
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)
},
)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| {
DomainError::internal_error("FileBlobRead", format!("hydrate mapping: {e}"))
})
}
/// Returns the user_id (owner) for a given file ID.
/// Mirrors `FolderDbRepository::get_folder_user_id`.
/// Used by the AuthorizationEngine for owner short-circuit.
@@ -1021,46 +1182,7 @@ impl FileReadPort for FileBlobReadRepository {
bind_idx += 1;
conditions.push(format!("fi.name ILIKE ${bind_idx}"));
}
if let Some(types) = &criteria.file_types
&& !types.is_empty()
{
bind_idx += 1;
conditions.push(format!(
"LOWER(SUBSTRING(fi.name FROM '\\.([^.]+)$')) = ANY(${bind_idx})"
));
}
if criteria.created_after.is_some() {
bind_idx += 1;
conditions.push(format!(
"EXTRACT(EPOCH FROM fi.created_at)::bigint >= ${bind_idx}"
));
}
if criteria.created_before.is_some() {
bind_idx += 1;
conditions.push(format!(
"EXTRACT(EPOCH FROM fi.created_at)::bigint <= ${bind_idx}"
));
}
if criteria.modified_after.is_some() {
bind_idx += 1;
conditions.push(format!(
"EXTRACT(EPOCH FROM fi.updated_at)::bigint >= ${bind_idx}"
));
}
if criteria.modified_before.is_some() {
bind_idx += 1;
conditions.push(format!(
"EXTRACT(EPOCH FROM fi.updated_at)::bigint <= ${bind_idx}"
));
}
if criteria.min_size.is_some() {
bind_idx += 1;
conditions.push(format!("fi.size >= ${bind_idx}"));
}
if criteria.max_size.is_some() {
bind_idx += 1;
conditions.push(format!("fi.size <= ${bind_idx}"));
}
bind_idx = push_criteria_filters(&mut conditions, bind_idx, criteria);
let where_clause = conditions.join(" AND ");
let limit_bind = bind_idx + 1;
@@ -1107,30 +1229,7 @@ impl FileReadPort for FileBlobReadRepository {
{
query = query.bind(super::like_escape(name));
}
if let Some(types) = &criteria.file_types
&& !types.is_empty()
{
let lower_types: Vec<String> = types.iter().map(|t| t.to_lowercase()).collect();
query = query.bind(lower_types);
}
if let Some(v) = criteria.created_after {
query = query.bind(v as i64);
}
if let Some(v) = criteria.created_before {
query = query.bind(v as i64);
}
if let Some(v) = criteria.modified_after {
query = query.bind(v as i64);
}
if let Some(v) = criteria.modified_before {
query = query.bind(v as i64);
}
if let Some(v) = criteria.min_size {
query = query.bind(v as i64);
}
if let Some(v) = criteria.max_size {
query = query.bind(v as i64);
}
query = bind_criteria_filters(query, criteria);
query = query.bind(limit).bind(offset);
+1
View File
@@ -23,6 +23,7 @@ pub mod path_service;
pub mod pg_acl_engine;
pub mod retry_blob_backend;
pub mod s3_blob_backend;
pub mod search_index;
pub mod share_unlock_cookie;
pub mod smtp_email_sender;
pub mod thumbnail_service;
@@ -0,0 +1,415 @@
//! Background drainer for `storage.search_index_dirty` — the asynchronous
//! half of content indexing (see migration `20260701000000_content_search_index`).
//!
//! The statement triggers on `storage.files` only append "index me" requests
//! to the queue, taking zero locks on user write paths. This worker turns the
//! requests into Tantivy mutations: every `interval_ms` it drains a batch,
//! re-reads the CURRENT file state (the queue row is a hint, not a payload),
//! extracts text once per unique blob, applies one batched Tantivy commit and
//! only then deletes the processed queue rows.
//!
//! Correctness invariants:
//! * At-least-once: queue rows are deleted AFTER the Tantivy commit. A
//! crash in between re-processes the batch — harmless, upserts are
//! idempotent (delete_term + add_document keyed by file_id).
//! * Deletes are selected by exact id (`id = ANY(...)`), never by range —
//! a transaction that began before our SELECT can commit a smaller id
//! afterwards, and a range delete would discard it unprocessed.
//! * Latest-op-wins per file within a batch; the authoritative state is
//! re-fetched from `storage.files` at drain time anyway (a file trashed
//! after its 'upsert' was queued simply turns into a delete).
//! * Extraction is keyed by blob hash (content-addressed): N files sharing
//! a blob cost ONE extraction, renames/moves cost zero re-extraction.
//! Terminal outcomes (ok/empty/failed/too_large) are cached in
//! `storage.blob_extracted_text`; transient blob-read errors store
//! nothing so the next event retries.
//!
//! Resource budget: single worker, one extraction at a time inside
//! `spawn_blocking`, single-threaded Tantivy writer — the pipeline trickles
//! along on the maintenance pool and never competes with request latency.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use sqlx::PgPool;
use tracing::{debug, error, info, instrument, warn};
use uuid::Uuid;
use crate::infrastructure::services::dedup_service::DedupService;
use crate::infrastructure::services::search_index::tantivy_content_index::{
EXTRACTOR_VERSION, IndexDocRecord, TantivyContentIndex,
};
use crate::infrastructure::services::search_index::text_extractor::{self, ExtractedText};
/// Queue rows drained per batch. Each row may cost a blob read + extraction,
/// so this is far smaller than the tree-etag drain batch.
const DRAIN_BATCH: i64 = 256;
/// Max batches per tick so a huge backlog (initial reseed) cannot monopolise
/// the maintenance connection within one tick.
const MAX_BATCHES_PER_TICK: u32 = 4;
/// Stored preview head per document (snippet source).
const PREVIEW_BYTES: usize = 16 * 1024;
/// Ticks between `blob_extracted_text` orphan sweeps (~1 h at the default
/// 1.5 s interval).
const ORPHAN_SWEEP_TICKS: u64 = 2400;
pub struct ContentIndexWorker {
maintenance_pool: Arc<PgPool>,
dedup: Arc<DedupService>,
index: Arc<TantivyContentIndex>,
interval_ms: u64,
max_extract_file_bytes: u64,
max_text_bytes: usize,
}
impl ContentIndexWorker {
pub fn new(
maintenance_pool: Arc<PgPool>,
dedup: Arc<DedupService>,
index: Arc<TantivyContentIndex>,
interval_ms: u64,
max_extract_file_bytes: u64,
max_text_bytes: usize,
) -> Self {
Self {
maintenance_pool,
dedup,
index,
// Floor the cadence so a misconfiguration can't busy-loop the
// maintenance pool.
interval_ms: interval_ms.max(200),
max_extract_file_bytes,
max_text_bytes,
}
}
/// Spawn the indexing loop. Fire-and-forget: the loop logs and survives
/// every error (an exited loop would silently freeze the index while the
/// queue grows), and the first drain runs immediately to absorb rows left
/// over from a previous run or the migration backfill.
#[instrument(skip(self))]
pub fn start(self, needs_reseed: bool) {
info!(
"Starting content-index worker (every {}ms, batch {}, reseed: {})",
self.interval_ms, DRAIN_BATCH, needs_reseed
);
tokio::spawn(async move {
if let Err(e) = self.prepare(needs_reseed).await {
error!("Content-index prepare failed (continuing with queue as-is): {e}");
}
let mut ticker =
tokio::time::interval(std::time::Duration::from_millis(self.interval_ms));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut ticks: u64 = 0;
loop {
ticker.tick().await;
for _ in 0..MAX_BATCHES_PER_TICK {
match self.drain_once().await {
Ok(0) => break,
Ok(drained) => {
debug!("Content-index drain: processed {drained} queue row(s)");
if drained < DRAIN_BATCH as usize {
break;
}
}
Err(e) => {
error!("Content-index drain failed (queue preserved, will retry): {e}");
break;
}
}
}
ticks += 1;
if ticks.is_multiple_of(ORPHAN_SWEEP_TICKS) {
self.sweep_orphaned_text().await;
}
}
});
}
/// Spawn the discard-only janitor used when content search is DISABLED:
/// the triggers are always installed, so something must keep the queue
/// from growing unboundedly. Re-enabling the feature reseeds from scratch
/// (index version marker), so discarding here loses nothing.
pub fn start_drain_only_janitor(maintenance_pool: Arc<PgPool>) {
info!("Content search disabled — starting queue janitor (discard-only)");
tokio::spawn(async move {
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticker.tick().await;
if let Err(e) = sqlx::query("DELETE FROM storage.search_index_dirty")
.execute(maintenance_pool.as_ref())
.await
{
error!("Content-search queue janitor failed: {e}");
}
}
});
}
/// Startup housekeeping: drop extraction rows from other extractor
/// versions (the reseed re-extracts them) and, when the on-disk index was
/// wiped, re-enqueue every live file.
async fn prepare(&self, needs_reseed: bool) -> Result<(), sqlx::Error> {
let dropped = sqlx::query("DELETE FROM storage.blob_extracted_text WHERE extractor <> $1")
.bind(EXTRACTOR_VERSION)
.execute(self.maintenance_pool.as_ref())
.await?
.rows_affected();
if dropped > 0 {
info!("Dropped {dropped} extraction row(s) from a previous extractor version");
}
if needs_reseed {
let queued = sqlx::query(
"INSERT INTO storage.search_index_dirty (file_id, op)
SELECT id, 'upsert' FROM storage.files WHERE NOT is_trashed",
)
.execute(self.maintenance_pool.as_ref())
.await?
.rows_affected();
info!("Content-index reseed: queued {queued} file(s) for indexing");
}
Ok(())
}
/// Drain and process one queue batch. Returns the number of queue rows
/// consumed (0 = queue empty).
async fn drain_once(&self) -> Result<usize, sqlx::Error> {
let rows: Vec<(i64, Uuid, String)> = sqlx::query_as(
"SELECT id, file_id, op FROM storage.search_index_dirty ORDER BY id LIMIT $1",
)
.bind(DRAIN_BATCH)
.fetch_all(self.maintenance_pool.as_ref())
.await?;
if rows.is_empty() {
return Ok(0);
}
let drained_ids: Vec<i64> = rows.iter().map(|r| r.0).collect();
// Latest op per file wins (rows are id-ordered).
let mut latest_op: HashMap<Uuid, bool> = HashMap::with_capacity(rows.len());
for (_, file_id, op) in &rows {
latest_op.insert(*file_id, op == "upsert");
}
let upsert_candidates: Vec<Uuid> = latest_op
.iter()
.filter_map(|(id, &upsert)| upsert.then_some(*id))
.collect();
let mut deletes: HashSet<Uuid> = latest_op
.iter()
.filter_map(|(id, &upsert)| (!upsert).then_some(*id))
.collect();
// Authoritative state re-read: a queued 'upsert' whose row vanished
// or got trashed in the meantime becomes a delete.
let files: Vec<(Uuid, String, String, String, String, i64)> =
if upsert_candidates.is_empty() {
Vec::new()
} else {
sqlx::query_as(
"SELECT fi.id, fi.user_id::text, fi.name, fi.blob_hash, fi.mime_type, fi.size
FROM storage.files fi
WHERE fi.id = ANY($1) AND NOT fi.is_trashed",
)
.bind(&upsert_candidates)
.fetch_all(self.maintenance_pool.as_ref())
.await?
};
let found: HashSet<Uuid> = files.iter().map(|f| f.0).collect();
deletes.extend(upsert_candidates.iter().filter(|id| !found.contains(id)));
// Per-blob text: batch-read the extraction cache, extract misses.
let wanted_hashes: Vec<String> = files
.iter()
.filter(|(_, _, name, _, mime, size)| {
text_extractor::supports(name, mime) && *size as u64 <= self.max_extract_file_bytes
})
.map(|f| f.3.clone())
.collect();
let mut text_by_hash: HashMap<String, Option<String>> = HashMap::new();
if !wanted_hashes.is_empty() {
let cached: Vec<(String, Option<String>, String)> = sqlx::query_as(
"SELECT blob_hash, text, status FROM storage.blob_extracted_text
WHERE blob_hash = ANY($1)",
)
.bind(&wanted_hashes)
.fetch_all(self.maintenance_pool.as_ref())
.await?;
for (hash, text, status) in cached {
text_by_hash.insert(hash, (status == "ok").then_some(text.unwrap_or_default()));
}
}
let mut records = Vec::with_capacity(files.len());
for (file_id, user_id, name, blob_hash, mime, size) in files {
let supported = text_extractor::supports(&name, &mime);
let content = if !supported {
None
} else if let Some(cached) = text_by_hash.get(&blob_hash) {
cached.clone()
} else {
let extracted = self
.extract_and_cache(&blob_hash, &name, &mime, size as u64)
.await;
text_by_hash.insert(blob_hash.clone(), extracted.clone());
extracted
};
let preview = content
.as_deref()
.map(|t| truncate_on_char(t, PREVIEW_BYTES));
records.push(IndexDocRecord {
file_id: file_id.to_string(),
user_id,
name,
content,
preview,
});
}
// One batched Tantivy commit, off the async runtime.
let index = self.index.clone();
let delete_ids: Vec<String> = deletes.iter().map(Uuid::to_string).collect();
let applied: Result<(), String> =
match tokio::task::spawn_blocking(move || index.apply_batch(records, delete_ids)).await
{
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(e.to_string()),
Err(e) => Err(format!("join: {e}")),
};
if let Err(e) = applied {
// Queue rows survive — the next tick retries the whole batch.
error!("Tantivy batch apply failed (will retry): {e}");
return Ok(0);
}
// Only now is the work durable in the index — drop the queue rows.
sqlx::query("DELETE FROM storage.search_index_dirty WHERE id = ANY($1)")
.bind(&drained_ids)
.execute(self.maintenance_pool.as_ref())
.await?;
Ok(drained_ids.len())
}
/// Read the blob (already size-capped), run the extractor on the blocking
/// pool, and persist the terminal outcome keyed by blob hash. Transient
/// read failures persist nothing — the next queue event retries.
async fn extract_and_cache(
&self,
blob_hash: &str,
name: &str,
mime: &str,
size: u64,
) -> Option<String> {
if size > self.max_extract_file_bytes {
self.store_extraction(blob_hash, None, "too_large").await;
return None;
}
let bytes = match self.dedup.read_blob_bytes(blob_hash).await {
Ok(bytes) => bytes,
Err(e) => {
warn!(
"Content-index blob read failed for {blob_hash} (will retry on next event): {e}"
);
return None;
}
};
let (name, mime, max_text) = (name.to_owned(), mime.to_owned(), self.max_text_bytes);
let outcome = tokio::task::spawn_blocking(move || {
text_extractor::extract(&name, &mime, &bytes, max_text)
})
.await
.unwrap_or_else(|e| ExtractedText::Failed(format!("join: {e}")));
match outcome {
ExtractedText::Text(text) => {
self.store_extraction(blob_hash, Some(&text), "ok").await;
Some(text)
}
ExtractedText::Empty => {
self.store_extraction(blob_hash, None, "empty").await;
None
}
ExtractedText::Failed(reason) => {
warn!("Text extraction failed for blob {blob_hash}: {reason}");
self.store_extraction(blob_hash, None, "failed").await;
None
}
ExtractedText::Unsupported => None,
}
}
async fn store_extraction(&self, blob_hash: &str, text: Option<&str>, status: &str) {
if let Err(e) = sqlx::query(
"INSERT INTO storage.blob_extracted_text (blob_hash, text, status, extractor)
VALUES ($1, $2, $3, $4)
ON CONFLICT (blob_hash) DO NOTHING",
)
.bind(blob_hash)
.bind(text)
.bind(status)
.bind(EXTRACTOR_VERSION)
.execute(self.maintenance_pool.as_ref())
.await
{
warn!("Failed to cache extraction for blob {blob_hash}: {e}");
}
}
/// Drop extraction rows whose blob no longer backs any live file. Uses
/// the `idx_files_blob_hash` index; runs hourly on the maintenance pool.
async fn sweep_orphaned_text(&self) {
match sqlx::query(
"DELETE FROM storage.blob_extracted_text bet
WHERE NOT EXISTS (SELECT 1 FROM storage.files f WHERE f.blob_hash = bet.blob_hash)",
)
.execute(self.maintenance_pool.as_ref())
.await
{
Ok(result) if result.rows_affected() > 0 => {
debug!(
"Content-index sweep: dropped {} orphaned extraction row(s)",
result.rows_affected()
);
}
Ok(_) => {}
Err(e) => error!("Content-index orphan sweep failed: {e}"),
}
}
}
/// Truncate on a char boundary at most `max_bytes` into `s`.
fn truncate_on_char(s: &str, max_bytes: usize) -> String {
if s.len() <= max_bytes {
return s.to_owned();
}
let mut end = max_bytes;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
s[..end].to_owned()
}
#[cfg(test)]
mod tests {
use super::truncate_on_char;
#[test]
fn truncates_on_char_boundary() {
assert_eq!(truncate_on_char("patatas", 4), "pata");
// 'ñ' is 2 bytes — a cut landing inside it must back off ("ñoño" is
// ñ:0-1 o:2 ñ:3-4 o:5, so a 4-byte cut falls mid-ñ and yields "ño").
assert_eq!(truncate_on_char("ñoño", 4), "ño");
assert_eq!(truncate_on_char("ok", 10), "ok");
}
}
@@ -0,0 +1,19 @@
//! Embedded full-text content index (Tantivy) and its feeding pipeline.
//!
//! Three pieces, mirroring the thumbnail/tree-etag architecture:
//!
//! * [`tantivy_content_index`] — the embedded BM25 index over file names and
//! extracted content. Lives on local disk (`{storage}/.search-index`),
//! single-writer, microsecond queries. A DERIVED artifact: PostgreSQL is
//! the source of truth and the index is rebuilt (reseeded) whenever its
//! on-disk schema version differs from the binary's.
//! * [`text_extractor`] — pure-Rust text extraction (plain text/code, PDF,
//! Office OOXML/ODF). CPU-bound, runs only on the background worker.
//! * [`content_index_worker`] — drains `storage.search_index_dirty` (fed by
//! statement triggers on `storage.files`), extracts text once per unique
//! blob (BLAKE3-keyed cache in `storage.blob_extracted_text`), and applies
//! batched Tantivy mutations. Never touches a request path.
pub mod content_index_worker;
pub mod tantivy_content_index;
pub mod text_extractor;
@@ -0,0 +1,545 @@
//! Embedded Tantivy index over file names + extracted content.
//!
//! Performance contract (the reason Tantivy was chosen):
//! * queries are memory-mapped posting-list lookups — µs to low ms even at
//! millions of documents, executed on the blocking pool (never stalls the
//! Tokio reactor);
//! * the single `IndexWriter` is owned by the background worker; request
//! paths only ever touch the lock-free `IndexReader`.
//!
//! Index layout: one document per live file.
//! * `file_id` — raw term, stored. Identity for upsert (delete_term + add).
//! * `user_id` — raw term. Every query is `Must`-filtered by it, and hits
//! are re-validated through SQL hydration afterwards (defense in depth).
//! * `name` — tokenized file name (boosted 3x at query time).
//! * `content` — tokenized extracted text (never stored — the index stays
//! small; snippets come from `preview`).
//! * `preview` — stored-only head of the extracted text used to render a
//! snippet around the first matched term.
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tantivy::collector::TopDocs;
use tantivy::directory::MmapDirectory;
use tantivy::query::{BooleanQuery, BoostQuery, FuzzyTermQuery, Occur, Query, TermQuery};
use tantivy::schema::{Field, IndexRecordOption, STORED, STRING, Schema, TEXT, Value as _};
use tantivy::snippet::SnippetGenerator;
use tantivy::tokenizer::TextAnalyzer;
use tantivy::{Index, IndexReader, IndexWriter, ReloadPolicy, TantivyDocument, Term, doc};
use async_trait::async_trait;
use uuid::Uuid;
use crate::application::ports::content_index_ports::{ContentHitDto, ContentIndexPort};
use crate::common::errors::DomainError;
/// Bump whenever the Tantivy schema OR the text extractor output changes in a
/// way that requires re-indexing. A mismatch with the on-disk marker wipes the
/// index directory and reseeds the dirty queue with every live file.
pub const INDEX_SCHEMA_VERSION: &str = "1";
/// Recorded in `storage.blob_extracted_text.extractor`; rows from another
/// version are dropped at worker startup (the reseed re-extracts them).
/// Keep in lockstep with [`INDEX_SCHEMA_VERSION`].
pub const EXTRACTOR_VERSION: &str = "rust-native-1";
/// Marker file inside the index directory carrying the schema version.
const META_FILE: &str = "oxicloud-index.version";
/// RAM budget for the single-threaded writer. Indexing is a trickle-feed
/// background task — one thread and a small heap keep the footprint
/// negligible next to the request-serving process.
const WRITER_HEAP_BYTES: usize = 64 * 1024 * 1024;
/// Hard cap on query tokens — a pathological query must not fan out into
/// dozens of fuzzy automata.
const MAX_QUERY_TOKENS: usize = 8;
/// Snippet length target, in characters.
const SNIPPET_MAX_CHARS: usize = 180;
/// Minimum token length for typo-tolerant (edit distance 1) matching.
/// Short tokens produce too many false positives under fuzzy matching.
const FUZZY_MIN_CHARS: usize = 5;
/// Minimum token length for prefix expansion of the LAST query token
/// (search-as-you-type behaviour).
const PREFIX_MIN_CHARS: usize = 3;
/// One file to (re-)index. `content`/`preview` are `None` for files without
/// extractable text (images, archives…) — their NAME is still indexed.
#[derive(Debug)]
pub struct IndexDocRecord {
pub file_id: String,
pub user_id: String,
pub name: String,
pub content: Option<String>,
pub preview: Option<String>,
}
#[derive(Clone, Copy)]
struct IndexFields {
file_id: Field,
user_id: Field,
name: Field,
content: Field,
preview: Field,
}
pub struct TantivyContentIndex {
/// Sole writer — owned by the background worker; the Mutex is never
/// contended on a request path. (Writer and reader each keep the
/// underlying `Index` alive.)
writer: Mutex<IndexWriter>,
reader: IndexReader,
/// Pre-cloned analyzer for query-side tokenization (matches the index
/// side: simple split + lowercase).
analyzer: TextAnalyzer,
fields: IndexFields,
}
impl TantivyContentIndex {
fn build_schema() -> (Schema, IndexFields) {
let mut builder = Schema::builder();
let fields = IndexFields {
file_id: builder.add_text_field("file_id", STRING | STORED),
user_id: builder.add_text_field("user_id", STRING),
name: builder.add_text_field("name", TEXT),
content: builder.add_text_field("content", TEXT),
preview: builder.add_text_field("preview", STORED),
};
(builder.build(), fields)
}
/// Open the index at `dir`, wiping and recreating it when the on-disk
/// version marker is absent or stale. Returns `(index, needs_reseed)`:
/// when `needs_reseed` is true the caller must re-enqueue every live file.
pub fn open_or_rebuild(dir: &Path) -> Result<(Self, bool), DomainError> {
let marker: PathBuf = dir.join(META_FILE);
let version_ok = std::fs::read_to_string(&marker)
.map(|v| v.trim() == INDEX_SCHEMA_VERSION)
.unwrap_or(false);
if !version_ok && dir.exists() {
std::fs::remove_dir_all(dir).map_err(|e| {
DomainError::internal_error(
"ContentIndex",
format!("wiping stale index dir {}: {e}", dir.display()),
)
})?;
}
std::fs::create_dir_all(dir).map_err(|e| {
DomainError::internal_error(
"ContentIndex",
format!("creating index dir {}: {e}", dir.display()),
)
})?;
let (schema, fields) = Self::build_schema();
let mmap = MmapDirectory::open(dir)
.map_err(|e| DomainError::internal_error("ContentIndex", format!("mmap dir: {e}")))?;
let index = Index::open_or_create(mmap, schema)
.map_err(|e| DomainError::internal_error("ContentIndex", format!("open: {e}")))?;
// Single writer thread: indexing is a background trickle, not a bulk
// load — keep the CPU/RAM footprint minimal.
let writer = index
.writer_with_num_threads::<TantivyDocument>(1, WRITER_HEAP_BYTES)
.map_err(|e| DomainError::internal_error("ContentIndex", format!("writer: {e}")))?;
let reader = index
.reader_builder()
.reload_policy(ReloadPolicy::OnCommitWithDelay)
.try_into()
.map_err(|e| DomainError::internal_error("ContentIndex", format!("reader: {e}")))?;
let analyzer = index
.tokenizer_for_field(fields.content)
.map_err(|e| DomainError::internal_error("ContentIndex", format!("analyzer: {e}")))?;
std::fs::write(&marker, INDEX_SCHEMA_VERSION).map_err(|e| {
DomainError::internal_error("ContentIndex", format!("writing version marker: {e}"))
})?;
Ok((
Self {
writer: Mutex::new(writer),
reader,
analyzer,
fields,
},
!version_ok,
))
}
/// Apply one drained queue batch: deletes, then upserts, then ONE commit.
/// Blocking (disk I/O + segment serialization) — call from the worker via
/// `spawn_blocking`. The caller deletes the queue rows only after this
/// returns `Ok`, so a crash in between re-processes the batch
/// (idempotent: upsert = delete_term + add).
pub fn apply_batch(
&self,
upserts: Vec<IndexDocRecord>,
deletes: Vec<String>,
) -> Result<(), DomainError> {
let mut writer = self
.writer
.lock()
.map_err(|_| DomainError::internal_error("ContentIndex", "writer mutex poisoned"))?;
for file_id in &deletes {
writer.delete_term(Term::from_field_text(self.fields.file_id, file_id));
}
for record in upserts {
writer.delete_term(Term::from_field_text(self.fields.file_id, &record.file_id));
let mut document = doc!(
self.fields.file_id => record.file_id,
self.fields.user_id => record.user_id,
self.fields.name => record.name,
);
if let Some(content) = record.content {
document.add_text(self.fields.content, content);
}
if let Some(preview) = record.preview {
document.add_text(self.fields.preview, preview);
}
writer
.add_document(document)
.map_err(|e| DomainError::internal_error("ContentIndex", format!("add: {e}")))?;
}
writer
.commit()
.map_err(|e| DomainError::internal_error("ContentIndex", format!("commit: {e}")))?;
Ok(())
}
/// Number of live documents — used by tests and the startup log line.
pub fn num_docs(&self) -> u64 {
self.reader.searcher().num_docs()
}
/// Tokenize `raw` with the index analyzer (simple split + lowercase).
fn query_tokens(analyzer: &TextAnalyzer, raw: &str) -> Vec<String> {
let mut analyzer = analyzer.clone();
let mut tokens = Vec::new();
let mut stream = analyzer.token_stream(raw);
while stream.advance() && tokens.len() < MAX_QUERY_TOKENS {
tokens.push(stream.token().text.clone());
}
tokens
}
/// Build the scored query: every token must match (in name OR content,
/// exact OR fuzzy OR — for the last token — prefix), and the whole thing
/// is `Must`-scoped to the user.
fn build_query(fields: IndexFields, user_id: &str, tokens: &[String]) -> Box<dyn Query> {
let mut clauses: Vec<(Occur, Box<dyn Query>)> = vec![(
Occur::Must,
Box::new(TermQuery::new(
Term::from_field_text(fields.user_id, user_id),
IndexRecordOption::Basic,
)),
)];
let last = tokens.len().saturating_sub(1);
for (i, token) in tokens.iter().enumerate() {
let name_term = Term::from_field_text(fields.name, token);
let content_term = Term::from_field_text(fields.content, token);
let mut alternatives: Vec<(Occur, Box<dyn Query>)> = vec![
(
Occur::Should,
// Name matches outrank content matches for the same term.
Box::new(BoostQuery::new(
Box::new(TermQuery::new(
name_term.clone(),
IndexRecordOption::WithFreqs,
)),
3.0,
)),
),
(
Occur::Should,
Box::new(TermQuery::new(
content_term.clone(),
IndexRecordOption::WithFreqs,
)),
),
];
if token.chars().count() >= FUZZY_MIN_CHARS {
// Edit distance 1 absorbs typos and most singular/plural
// morphology ("patata" ↔ "patatas") without a stemmer.
alternatives.push((
Occur::Should,
Box::new(FuzzyTermQuery::new(name_term.clone(), 1, true)),
));
alternatives.push((
Occur::Should,
Box::new(FuzzyTermQuery::new(content_term.clone(), 1, true)),
));
}
if i == last && token.chars().count() >= PREFIX_MIN_CHARS {
// Search-as-you-type: the token still being typed matches as
// a prefix ("pata" → "patatas").
alternatives.push((
Occur::Should,
Box::new(FuzzyTermQuery::new_prefix(name_term, 0, true)),
));
alternatives.push((
Occur::Should,
Box::new(FuzzyTermQuery::new_prefix(content_term, 0, true)),
));
}
clauses.push((Occur::Must, Box::new(BooleanQuery::new(alternatives))));
}
Box::new(BooleanQuery::new(clauses))
}
/// Blocking search core — runs on the blocking pool via the port impl.
fn search_blocking(
searcher: tantivy::Searcher,
analyzer: TextAnalyzer,
fields: IndexFields,
user_id: &str,
raw_query: &str,
limit: usize,
) -> Result<Vec<ContentHitDto>, DomainError> {
let tokens = Self::query_tokens(&analyzer, raw_query);
if tokens.is_empty() {
return Ok(Vec::new());
}
let query = Self::build_query(fields, user_id, &tokens);
let top_docs = searcher
.search(&query, &TopDocs::with_limit(limit.max(1)).order_by_score())
.map_err(|e| DomainError::internal_error("ContentIndex", format!("search: {e}")))?;
// Snippets highlight CONTENT matches; an empty fragment means the hit
// came from the name (or a fuzzy variant) — no snippet then.
let snippet_generator = SnippetGenerator::create(&searcher, &*query, fields.content)
.map(|mut g| {
g.set_max_num_chars(SNIPPET_MAX_CHARS);
g
})
.ok();
let mut hits = Vec::with_capacity(top_docs.len());
for (score, address) in top_docs {
let document: TantivyDocument = searcher.doc(address).map_err(|e| {
DomainError::internal_error("ContentIndex", format!("doc fetch: {e}"))
})?;
let Some(file_id) = document
.get_first(fields.file_id)
.and_then(|v| v.as_str())
.map(str::to_owned)
else {
continue;
};
let snippet = document
.get_first(fields.preview)
.and_then(|v| v.as_str())
.and_then(|preview| {
let generator = snippet_generator.as_ref()?;
let fragment = generator.snippet(preview).fragment().trim().to_owned();
(!fragment.is_empty()).then_some(fragment)
});
hits.push(ContentHitDto {
file_id,
score,
snippet,
});
}
Ok(hits)
}
}
#[async_trait]
impl ContentIndexPort for TantivyContentIndex {
async fn search_content(
&self,
user_id: Uuid,
query: &str,
limit: usize,
) -> Result<Vec<ContentHitDto>, DomainError> {
let searcher = self.reader.searcher();
let analyzer = self.analyzer.clone();
let fields = self.fields;
let user_id = user_id.to_string();
let query = query.to_owned();
tokio::task::spawn_blocking(move || {
Self::search_blocking(searcher, analyzer, fields, &user_id, &query, limit)
})
.await
.map_err(|e| DomainError::internal_error("ContentIndex", format!("join: {e}")))?
}
}
#[cfg(test)]
mod tests {
use super::*;
fn record(file_id: &str, user_id: &str, name: &str, content: Option<&str>) -> IndexDocRecord {
IndexDocRecord {
file_id: file_id.to_owned(),
user_id: user_id.to_owned(),
name: name.to_owned(),
content: content.map(str::to_owned),
preview: content.map(str::to_owned),
}
}
fn search(index: &TantivyContentIndex, user_id: &str, query: &str) -> Vec<ContentHitDto> {
// Force a reader reload — OnCommitWithDelay is asynchronous and tests
// must observe the commit immediately.
index.reader.reload().unwrap();
TantivyContentIndex::search_blocking(
index.reader.searcher(),
index.analyzer.clone(),
index.fields,
user_id,
query,
32,
)
.unwrap()
}
#[test]
fn index_name_and_content_with_fuzzy_prefix_and_user_isolation() {
let dir = tempfile::tempdir().unwrap();
let (index, needs_reseed) = TantivyContentIndex::open_or_rebuild(dir.path()).unwrap();
assert!(needs_reseed, "fresh dir must request a reseed");
index
.apply_batch(
vec![
record("f1", "user-a", "patatas-fritas.jpg", None),
record(
"f2",
"user-a",
"recetas.pdf",
Some("la mejor receta de patatas bravas del mundo"),
),
record(
"f3",
"user-b",
"patatas-ajenas.txt",
Some("patatas de otro usuario"),
),
record("f4", "user-a", "informe.txt", Some("nada relacionado aqui")),
],
Vec::new(),
)
.unwrap();
// Exact term: name hit + content hit for user-a only.
let hits = search(&index, "user-a", "patatas");
let ids: Vec<&str> = hits.iter().map(|h| h.file_id.as_str()).collect();
assert!(ids.contains(&"f1"), "name match expected: {ids:?}");
assert!(ids.contains(&"f2"), "content match expected: {ids:?}");
assert!(!ids.contains(&"f3"), "other user's file leaked: {ids:?}");
assert!(!ids.contains(&"f4"), "non-matching file returned: {ids:?}");
// The content hit carries a snippet around the matched term.
let content_hit = hits.iter().find(|h| h.file_id == "f2").unwrap();
assert!(
content_hit
.snippet
.as_deref()
.unwrap_or("")
.contains("patatas"),
"snippet should surround the match: {:?}",
content_hit.snippet
);
// Fuzzy (distance 1): singular finds plural.
let ids: Vec<String> = search(&index, "user-a", "patata")
.into_iter()
.map(|h| h.file_id)
.collect();
assert!(
ids.contains(&"f2".to_owned()),
"fuzzy match expected: {ids:?}"
);
// Prefix on the last token (search-as-you-type).
let ids: Vec<String> = search(&index, "user-a", "pata")
.into_iter()
.map(|h| h.file_id)
.collect();
assert!(
ids.contains(&"f1".to_owned()),
"prefix match expected: {ids:?}"
);
}
#[test]
fn upsert_replaces_and_delete_removes() {
let dir = tempfile::tempdir().unwrap();
let (index, _) = TantivyContentIndex::open_or_rebuild(dir.path()).unwrap();
index
.apply_batch(
vec![record(
"f1",
"u",
"old-name.txt",
Some("contenido original"),
)],
Vec::new(),
)
.unwrap();
index
.apply_batch(
vec![record("f1", "u", "renamed.txt", Some("contenido original"))],
Vec::new(),
)
.unwrap();
assert!(
search(&index, "u", "old").is_empty(),
"stale doc survived upsert"
);
assert_eq!(search(&index, "u", "renamed").len(), 1);
index
.apply_batch(Vec::new(), vec!["f1".to_owned()])
.unwrap();
assert!(
search(&index, "u", "renamed").is_empty(),
"deleted doc still found"
);
}
#[test]
fn reopen_preserves_documents_and_version_mismatch_wipes() {
let dir = tempfile::tempdir().unwrap();
{
let (index, _) = TantivyContentIndex::open_or_rebuild(dir.path()).unwrap();
index
.apply_batch(vec![record("f1", "u", "persistente.txt", None)], Vec::new())
.unwrap();
}
// Same version: documents survive, no reseed requested.
{
let (index, needs_reseed) = TantivyContentIndex::open_or_rebuild(dir.path()).unwrap();
assert!(!needs_reseed);
assert_eq!(index.num_docs(), 1);
}
// Stale version marker: wipe + reseed.
std::fs::write(dir.path().join(META_FILE), "0-stale").unwrap();
let (index, needs_reseed) = TantivyContentIndex::open_or_rebuild(dir.path()).unwrap();
assert!(needs_reseed);
assert_eq!(index.num_docs(), 0);
}
}
@@ -0,0 +1,418 @@
//! Pure-Rust text extraction for the content index.
//!
//! Supported: plain text/code/markup, PDF (text layer), Office OOXML
//! (docx/xlsx/pptx) and OpenDocument (odt/ods/odp). Images, media and
//! archives are reported as [`ExtractedText::Unsupported`] WITHOUT reading
//! the blob (the worker checks [`supports`] first).
//!
//! Everything here is CPU-bound and synchronous — the worker runs it inside
//! `spawn_blocking`, one extraction at a time, so user-facing latency is
//! never affected. Output is whitespace-normalized and hard-capped at the
//! caller-provided byte budget.
use std::io::{BufReader, Cursor};
use std::panic::{AssertUnwindSafe, catch_unwind};
use quick_xml::events::Event;
/// Outcome of one extraction attempt.
#[derive(Debug)]
pub enum ExtractedText {
/// Usable text (normalized, capped).
Text(String),
/// Extractor ran fine but produced no text (e.g. empty document,
/// scanned PDF without a text layer, binary masquerading as text).
Empty,
/// No extractor handles this name/MIME combination.
Unsupported,
/// Extractor failed or panicked — terminal for this blob (recorded so it
/// is never retried until the extractor version bumps).
Failed(String),
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum Kind {
Plain,
Pdf,
Docx,
Xlsx,
Pptx,
Odf,
}
/// MIME types (beyond `text/*`) parsed as plain text.
const TEXTUAL_MIMES: &[&str] = &[
"application/json",
"application/ld+json",
"application/xml",
"application/javascript",
"application/x-javascript",
"application/x-yaml",
"application/yaml",
"application/toml",
"application/x-sh",
"application/x-shellscript",
"application/sql",
"image/svg+xml",
];
/// Extensions parsed as plain text when the MIME type is generic
/// (`application/octet-stream` uploads are common on WebDAV clients).
const TEXTUAL_EXTENSIONS: &[&str] = &[
"txt", "md", "markdown", "csv", "tsv", "json", "xml", "yaml", "yml", "toml", "ini", "cfg",
"conf", "log", "rs", "js", "mjs", "ts", "jsx", "tsx", "css", "scss", "html", "htm", "py", "rb",
"go", "java", "c", "h", "cpp", "hpp", "cs", "php", "sh", "sql", "tex", "svg",
];
fn extension_of(name: &str) -> Option<String> {
name.rsplit_once('.').map(|(_, ext)| ext.to_lowercase())
}
fn classify(name: &str, mime: &str) -> Option<Kind> {
let mime = mime
.split(';')
.next()
.unwrap_or_default()
.trim()
.to_lowercase();
if mime.starts_with("text/") || TEXTUAL_MIMES.contains(&mime.as_str()) {
return Some(Kind::Plain);
}
match mime.as_str() {
"application/pdf" => return Some(Kind::Pdf),
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => {
return Some(Kind::Docx);
}
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => {
return Some(Kind::Xlsx);
}
"application/vnd.openxmlformats-officedocument.presentationml.presentation" => {
return Some(Kind::Pptx);
}
"application/vnd.oasis.opendocument.text"
| "application/vnd.oasis.opendocument.spreadsheet"
| "application/vnd.oasis.opendocument.presentation" => return Some(Kind::Odf),
_ => {}
}
// Generic MIME — fall back to the extension.
match extension_of(name)?.as_str() {
ext if TEXTUAL_EXTENSIONS.contains(&ext) => Some(Kind::Plain),
"pdf" => Some(Kind::Pdf),
"docx" => Some(Kind::Docx),
"xlsx" => Some(Kind::Xlsx),
"pptx" => Some(Kind::Pptx),
"odt" | "ods" | "odp" => Some(Kind::Odf),
_ => None,
}
}
/// Whether [`extract`] has an extractor for this file — the worker calls this
/// BEFORE reading the blob, so unsupported content (photos, video, archives)
/// costs zero I/O.
pub fn supports(name: &str, mime: &str) -> bool {
classify(name, mime).is_some()
}
/// Extract plain text from `bytes`, capped at `max_text_bytes` of UTF-8.
pub fn extract(name: &str, mime: &str, bytes: &[u8], max_text_bytes: usize) -> ExtractedText {
let Some(kind) = classify(name, mime) else {
return ExtractedText::Unsupported;
};
let result = match kind {
Kind::Plain => extract_plain(bytes, max_text_bytes),
Kind::Pdf => extract_pdf(bytes, max_text_bytes),
Kind::Docx => {
extract_zipped_xml(bytes, ZipSource::Fixed("word/document.xml"), max_text_bytes)
}
Kind::Xlsx => extract_zipped_xml(
bytes,
ZipSource::Fixed("xl/sharedStrings.xml"),
max_text_bytes,
),
Kind::Pptx => extract_zipped_xml(bytes, ZipSource::Slides, max_text_bytes),
Kind::Odf => extract_zipped_xml(bytes, ZipSource::Fixed("content.xml"), max_text_bytes),
};
match result {
Ok(text) if text.is_empty() => ExtractedText::Empty,
Ok(text) => ExtractedText::Text(text),
Err(reason) => ExtractedText::Failed(reason),
}
}
/// Collapse whitespace runs and cap at `max_bytes` (on a char boundary).
/// Normalization keeps the index lean and makes stored previews readable.
fn normalize_and_cap(text: &str, max_bytes: usize) -> String {
let mut out = String::with_capacity(text.len().min(max_bytes));
for word in text.split_whitespace() {
if out.len() + word.len() + 1 > max_bytes {
break;
}
if !out.is_empty() {
out.push(' ');
}
out.push_str(word);
}
out
}
fn extract_plain(bytes: &[u8], max_text_bytes: usize) -> Result<String, String> {
// NUL byte in the head = binary masquerading under a textual name/MIME.
if bytes.iter().take(8192).any(|&b| b == 0) {
return Ok(String::new());
}
// Decode at most ~2x the budget — normalization only shrinks text, so
// anything beyond that can never reach the output.
let slice_end = bytes.len().min(max_text_bytes.saturating_mul(2));
let text = String::from_utf8_lossy(&bytes[..slice_end]);
Ok(normalize_and_cap(&text, max_text_bytes))
}
fn extract_pdf(bytes: &[u8], max_text_bytes: usize) -> Result<String, String> {
// pdf-extract is known to panic on malformed documents; a poisoned blob
// must mark itself 'failed' instead of taking the worker down.
let outcome = catch_unwind(AssertUnwindSafe(|| {
pdf_extract::extract_text_from_mem(bytes)
}));
match outcome {
Ok(Ok(text)) => Ok(normalize_and_cap(&text, max_text_bytes)),
Ok(Err(e)) => Err(format!("pdf: {e}")),
Err(_) => Err("pdf: extractor panicked".to_owned()),
}
}
enum ZipSource {
/// One well-known entry (docx body, xlsx shared strings, ODF content).
Fixed(&'static str),
/// Every `ppt/slides/slideN.xml` entry.
Slides,
}
fn extract_zipped_xml(
bytes: &[u8],
source: ZipSource,
max_text_bytes: usize,
) -> Result<String, String> {
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("zip: {e}"))?;
let entries: Vec<String> = match source {
ZipSource::Fixed(name) => vec![name.to_owned()],
ZipSource::Slides => {
let mut slides: Vec<String> = archive
.file_names()
.filter(|n| n.starts_with("ppt/slides/slide") && n.ends_with(".xml"))
.map(str::to_owned)
.collect();
slides.sort();
slides
}
};
let mut text = String::new();
for entry in entries {
let Ok(file) = archive.by_name(&entry) else {
// Tolerated: e.g. an xlsx with no shared strings table.
continue;
};
collect_xml_text(BufReader::new(file), &mut text, max_text_bytes)
.map_err(|e| format!("{entry}: {e}"))?;
if text.len() >= max_text_bytes {
break;
}
}
Ok(normalize_and_cap(&text, max_text_bytes))
}
/// Append every XML text node to `out` (capped). Text RUNS are concatenated
/// without separators — OOXML splits words across `<w:t>` runs arbitrarily —
/// while paragraph/cell boundaries insert whitespace so distinct words never
/// fuse together.
fn collect_xml_text<R: std::io::BufRead>(
reader: R,
out: &mut String,
max_bytes: usize,
) -> Result<(), String> {
let mut xml = quick_xml::Reader::from_reader(reader);
let mut buf = Vec::new();
loop {
if out.len() >= max_bytes {
return Ok(());
}
match xml.read_event_into(&mut buf) {
Ok(Event::Text(t)) => {
if let Ok(decoded) = t.xml_content() {
out.push_str(&decoded);
}
}
Ok(Event::GeneralRef(r)) => {
// quick-xml emits entity references as separate events.
// Character refs (&#65;) and the predefined five resolve to
// their literal character; unknown custom entities are
// dropped (no DTD resolution).
if let Ok(Some(ch)) = r.resolve_char_ref() {
out.push(ch);
} else if let Ok(name) = r.decode() {
match name.as_ref() {
"amp" => out.push('&'),
"lt" => out.push('<'),
"gt" => out.push('>'),
"apos" => out.push('\''),
"quot" => out.push('"'),
_ => {}
}
}
}
Ok(Event::End(e)) => {
// Paragraphs (docx w:p, pptx a:p, ODF text:p/text:h), table
// rows and xlsx shared-string items all separate words.
let local = e.local_name();
if matches!(local.as_ref(), b"p" | b"h" | b"si" | b"row" | b"br") {
out.push('\n');
}
}
Ok(Event::Empty(e)) => {
// Self-closing breaks/tabs inside a paragraph (<w:br/>, <w:tab/>).
let local = e.local_name();
if matches!(local.as_ref(), b"br" | b"tab") {
out.push(' ');
}
}
Ok(Event::Eof) => return Ok(()),
Ok(_) => {}
Err(e) => return Err(format!("xml: {e}")),
}
buf.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use zip::write::SimpleFileOptions;
fn build_zip(entries: &[(&str, &str)]) -> Vec<u8> {
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, content) in entries {
writer
.start_file(*name, SimpleFileOptions::default())
.unwrap();
writer.write_all(content.as_bytes()).unwrap();
}
writer.finish().unwrap().into_inner()
}
fn text_of(outcome: ExtractedText) -> String {
match outcome {
ExtractedText::Text(t) => t,
other => panic!("expected Text, got {other:?}"),
}
}
#[test]
fn plain_text_is_normalized_and_capped() {
let out = extract(
"notas.txt",
"text/plain",
b"receta de\n\npatatas bravas",
1024,
);
assert_eq!(text_of(out), "receta de patatas bravas");
let big = "palabra ".repeat(1000);
let out = text_of(extract("big.txt", "text/plain", big.as_bytes(), 64));
assert!(out.len() <= 64, "cap exceeded: {}", out.len());
assert!(out.ends_with("palabra"), "must cut on word boundary");
}
#[test]
fn binary_masquerading_as_text_yields_empty() {
let mut bytes = b"PK\x03\x04".to_vec();
bytes.extend_from_slice(&[0u8; 64]);
assert!(matches!(
extract("raro.txt", "text/plain", &bytes, 1024),
ExtractedText::Empty
));
}
#[test]
fn unsupported_types_are_reported_without_reading() {
assert!(!supports("foto.jpg", "image/jpeg"));
assert!(matches!(
extract("foto.jpg", "image/jpeg", &[0xFF, 0xD8], 1024),
ExtractedText::Unsupported
));
assert!(supports("recetas.pdf", "application/pdf"));
assert!(
supports("notas", "text/plain"),
"MIME wins without extension"
);
}
#[test]
fn docx_runs_concatenate_and_paragraphs_separate() {
let body = r#"<?xml version="1.0"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:p><w:r><w:t>pata</w:t></w:r><w:r><w:t>tas</w:t></w:r></w:p>
<w:p><w:r><w:t>bravas &amp; ali oli</w:t></w:r></w:p>
</w:body>
</w:document>"#;
let bytes = build_zip(&[("word/document.xml", body)]);
let out = text_of(extract(
"receta.docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
&bytes,
4096,
));
assert_eq!(out, "patatas bravas & ali oli");
}
#[test]
fn xlsx_shared_strings_extract() {
let shared = r#"<?xml version="1.0"?>
<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="2">
<si><t>patatas</t></si>
<si><t>900 kg</t></si>
</sst>"#;
let bytes = build_zip(&[("xl/sharedStrings.xml", shared)]);
let out = text_of(extract(
"stock.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
&bytes,
4096,
));
assert_eq!(out, "patatas 900 kg");
}
#[test]
fn odt_content_extracts_by_extension_fallback() {
let content = r#"<?xml version="1.0"?>
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0">
<office:body><office:text>
<text:p>tortilla de patatas</text:p>
</office:text></office:body>
</office:document-content>"#;
let bytes = build_zip(&[("content.xml", content)]);
// Generic MIME — classification must fall back to the .odt extension.
let out = text_of(extract(
"receta.odt",
"application/octet-stream",
&bytes,
4096,
));
assert_eq!(out, "tortilla de patatas");
}
#[test]
fn corrupt_pdf_fails_terminally_instead_of_panicking() {
assert!(matches!(
extract("roto.pdf", "application/pdf", b"definitely not a pdf", 4096),
ExtractedText::Failed(_)
));
}
}