#![cfg(feature = "documents")]
use std::path::Path;
use std::sync::OnceLock;
use ahash::AHashSet;
use anyhow::Context as _;
use xberg::core::mime;
use xberg::embeddings::{EMBEDDING_PRESETS, EmbeddingPreset};
use crate::config::{DocumentsConfig, LlmConfig, ResourcesConfig};
use crate::extract::doc::{DocConfig, FileMapDoc, extract_doc};
use crate::extract::doc_guard::ChunkCutovers;
use crate::hashing::{self, Hash};
use crate::lance::DocumentRow;
use crate::scanner::EmbedMode;
use crate::scanner_filter::Filters;
use crate::store::Store;
#[derive(Debug, Clone)]
pub(crate) struct PendingDocBatch {
pub rel_path: String,
pub blob_hash: String,
pub doc_scope: String,
pub chunk_count: usize,
pub embedding_dim: u16,
pub emit_rows: bool,
pub embedded: bool,
pub embed_attempted: bool,
pub reused: bool,
pub degraded: bool,
}
pub(crate) fn preset_dim(name: &str) -> anyhow::Result<u16> {
let preset: &EmbeddingPreset = EMBEDDING_PRESETS
.iter()
.find(|p| p.name == name)
.with_context(|| format!("unknown xberg embedding preset: {name}"))?;
u16::try_from(preset.dimensions)
.with_context(|| format!("preset {name} dimensions {} exceeds u16", preset.dimensions))
}
pub(crate) fn doc_config_from(
cfg: &DocumentsConfig,
llm: &LlmConfig,
resources: &ResourcesConfig,
embed: bool,
) -> DocConfig {
DocConfig {
max_pages: cfg.max_pages,
extraction_timeout_secs: cfg.extraction_timeout_secs,
max_chunks_per_document: cfg.max_chunks_per_document,
chunk_cutovers: ChunkCutovers {
markdown_bytes: cfg.markdown_chunk_max_bytes,
plain_text_bytes: cfg.plain_text_chunk_max_bytes,
},
max_characters: cfg.max_characters,
overlap: cfg.overlap,
embedding_preset: Some(cfg.embedding_preset.clone()),
embed,
language: cfg.language.clone(),
keywords: cfg.keywords.clone(),
ner: cfg.ner.clone(),
summarization: cfg.summarization.clone(),
llm: llm.clone(),
embed_max_threads: resources.effective_embed_threads(cfg.embed_max_threads),
embed_batch_size: resources.embed_batch_size,
document_models: resources.document_models,
onnx_provider: resources.onnx_provider,
max_archive_bytes: usize::try_from(cfg.max_archive_bytes).unwrap_or(usize::MAX),
max_archive_entries: cfg.max_archive_entries,
}
}
const ARCHIVE_EXTENSIONS: &[&str] = &[
"zip", "tar", "gz", "tgz", "bz2", "tbz2", "xz", "txz", "zst", "zstd", "7z", "rar", "lz", "lz4", "lzma", "br",
"cab", "ar", "iso", "dmg", "jar", "war", "ear", "apk", "whl", "egg", "deb", "rpm", "nupkg", "pkg", "msi", "crate",
];
const BINARY_EXTENSIONS: &[&str] = &[
"so", "dylib", "dll", "a", "o", "obj", "bin", "exe", "wasm", "class", "pyc", "pyo", "pyd", "node", "pack", "idx",
];
const UNSAFE_DOCUMENT_EXTENSIONS: &[&str] = &["enw"];
const UNSAFE_DOCUMENT_MIME: &[&str] = &["application/x-endnote+xml"];
const ARCHIVE_MIME: &[&str] = &[
"application/zip",
"application/x-tar",
"application/gzip",
"application/x-7z-compressed",
"application/java-archive",
"application/vnd.rar",
];
const BINARY_MIME: &[&str] = &[
"application/wasm",
"application/x-executable",
"application/x-sharedlib",
"application/x-mach-binary",
"application/octet-stream",
"audio/",
"video/",
"font/",
];
fn ext_set(
cell: &'static OnceLock<AHashSet<&'static str>>,
exts: &'static [&'static str],
) -> &'static AHashSet<&'static str> {
cell.get_or_init(|| exts.iter().copied().collect())
}
fn archive_ext_set() -> &'static AHashSet<&'static str> {
static SET: OnceLock<AHashSet<&'static str>> = OnceLock::new();
ext_set(&SET, ARCHIVE_EXTENSIONS)
}
fn binary_ext_set() -> &'static AHashSet<&'static str> {
static SET: OnceLock<AHashSet<&'static str>> = OnceLock::new();
ext_set(&SET, BINARY_EXTENSIONS)
}
fn is_denied_binary_or_archive(abs: &Path, mime_type: &str, cfg: &DocumentsConfig) -> bool {
if let Some(ext) = abs.extension().and_then(|e| e.to_str()) {
let ext_lower = ext.to_ascii_lowercase();
if UNSAFE_DOCUMENT_EXTENSIONS.contains(&ext_lower.as_str())
|| binary_ext_set().contains(ext_lower.as_str())
|| cfg
.extension_denylist
.iter()
.any(|e| crate::config::rules::normalize_extension(e) == ext_lower)
{
return true;
}
if !cfg.extract_archives && archive_ext_set().contains(ext_lower.as_str()) {
return true;
}
}
if BINARY_MIME.iter().any(|entry| matches_mime(entry, mime_type)) {
return true;
}
if UNSAFE_DOCUMENT_MIME.iter().any(|entry| matches_mime(entry, mime_type)) {
return true;
}
!cfg.extract_archives && ARCHIVE_MIME.iter().any(|entry| matches_mime(entry, mime_type))
}
pub(crate) fn should_extract_document(abs: &Path, cfg: &DocumentsConfig) -> Option<String> {
if !cfg.enabled {
return None;
}
let mime_type = mime::detect_mime_type(abs, false).ok()?;
if is_denied_binary_or_archive(abs, &mime_type, cfg) {
return None;
}
if cfg.mime_allowlist.is_empty() {
return Some(mime_type);
}
let allowed = cfg.mime_allowlist.iter().any(|entry| matches_mime(entry, &mime_type));
if allowed { Some(mime_type) } else { None }
}
fn matches_mime(entry: &str, mime_type: &str) -> bool {
if entry == mime_type {
return true;
}
if let Some(prefix) = entry.strip_suffix('/') {
return mime_type.starts_with(prefix) && mime_type.as_bytes().get(prefix.len()) == Some(&b'/');
}
false
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn extract_and_persist_doc(
store: &Store,
rel: &str,
abs: &Path,
hash: &Hash,
mime_type: &str,
cfg: &DocumentsConfig,
llm: &LlmConfig,
resources: &ResourcesConfig,
filters: &Filters,
scope: &str,
mode: EmbedMode,
) -> Result<Option<PendingDocBatch>, anyhow::Error> {
let embed = doc_embed_requested(rel, cfg, mode, filters);
let hex_buf = hashing::hex_buf(hash);
let hash_hex = hashing::hex_str(&hex_buf);
if let Some(mut cached) = store.read_doc_by_hex(hash_hex).ok().flatten()
&& cached_doc_is_reusable(&cached, cfg, embed, &filters.doc_digest)
{
if cached.config_digest.is_empty() {
cached.config_digest.clone_from(&filters.doc_digest);
store
.write_doc(hash, &cached)
.with_context(|| format!("stamp doc blob for {rel}"))?;
}
store.touch_doc_blob(hash_hex);
return Ok(Some(pending_from_doc(&cached, rel, hash_hex, scope, cfg, embed, true)));
}
let doc_config = doc_config_from(cfg, llm, resources, embed);
let mut doc: FileMapDoc =
extract_doc(abs, Some(mime_type), &doc_config).with_context(|| format!("extract document {rel}"))?;
doc.config_digest.clone_from(&filters.doc_digest);
store
.write_doc(hash, &doc)
.with_context(|| format!("write doc blob for {rel}"))?;
Ok(Some(pending_from_doc(&doc, rel, hash_hex, scope, cfg, embed, false)))
}
pub(crate) fn doc_embed_requested(rel: &str, cfg: &DocumentsConfig, mode: EmbedMode, filters: &Filters) -> bool {
matches!(mode, EmbedMode::Inline) && cfg.embed && filters.doc_embed_allowed(rel)
}
pub(crate) fn doc_entry_settled(
existing: &crate::store::DocEntry,
hash_hex: &str,
embedding_preset: &str,
embed_requested: bool,
config_digest: &str,
reflush: bool,
) -> bool {
existing.hash_hex == hash_hex
&& existing.embedding_preset == embedding_preset
&& existing.config_digest == config_digest
&& !(embed_requested && reflush)
&& (existing.embedded || existing.embed_attempted || !embed_requested)
}
fn cached_doc_is_reusable(cached: &FileMapDoc, cfg: &DocumentsConfig, embed: bool, config_digest: &str) -> bool {
if !cached.config_digest.is_empty() && cached.config_digest != config_digest {
return false;
}
if !embed || cached.chunks.is_empty() {
return true;
}
let want_dim = preset_dim(&cfg.embedding_preset).ok();
cached.embedding_dim > 0
&& Some(cached.embedding_dim) == want_dim
&& cached.embedding_model == cfg.embedding_preset
&& cached
.chunks
.iter()
.all(|c| c.embedding.len() == cached.embedding_dim as usize)
}
fn pending_from_doc(
doc: &FileMapDoc,
rel: &str,
blob_hash: &str,
scope: &str,
cfg: &DocumentsConfig,
embed: bool,
reused: bool,
) -> PendingDocBatch {
let chunk_count = doc.chunks.len();
let embedding_dim = doc.embedding_dim;
let over_cap = chunk_count > cfg.max_chunks_per_document;
if over_cap {
tracing::warn!(
rel,
chunks = chunk_count,
cap = cfg.max_chunks_per_document,
"document exceeds max_chunks_per_document; caching blob but skipping vector rows"
);
}
let emit_rows = embed && embedding_dim > 0 && chunk_count > 0 && !over_cap;
PendingDocBatch {
rel_path: rel.to_string(),
blob_hash: blob_hash.to_string(),
doc_scope: scope.to_string(),
chunk_count,
embedding_dim,
emit_rows,
embedded: !embed || embedding_dim > 0 || chunk_count == 0,
embed_attempted: embed && !reused,
reused,
degraded: doc.linear_chunked,
}
}
fn build_doc_rows(doc: FileMapDoc, rel: &str, scope: &str) -> Vec<DocumentRow> {
let scope_owned = scope.to_string();
let rel_owned = rel.to_string();
let mime_owned = doc.mime_type;
doc.chunks
.into_iter()
.enumerate()
.map(|(idx, chunk)| DocumentRow {
scope: scope_owned.clone(),
path: rel_owned.clone(),
chunk_idx: u32::try_from(idx).unwrap_or(u32::MAX),
mime_type: mime_owned.clone(),
text: chunk.text,
byte_start: chunk.byte_start,
byte_end: chunk.byte_end,
embedding: chunk.embedding,
})
.collect()
}
pub(crate) fn delete_stale_documents(store: &mut Store, config: &crate::config::Config, scope: &str, stale: &[String]) {
if stale.is_empty() {
return;
}
for path in stale {
store.remove_doc(path);
}
let Some(lance) = open_existing_doc_lance(store, config, "doc stale purge") else {
return;
};
for path in stale {
let doc_scope = doc_scope_for(path, scope, config);
if let Err(error) = lance.replace_document(doc_scope.as_ref(), path, Vec::new()) {
tracing::warn!(
rel = %path,
?error,
"doc stale purge failed; search_documents may return a removed path"
);
}
if let Err(error) = lance.replace_doc_links(doc_scope.as_ref(), path, Vec::new()) {
tracing::warn!(rel = %path, ?error, "doc links stale purge failed; doc↔code edges may be stale");
}
}
}
fn open_existing_doc_lance(
store: &mut Store,
config: &crate::config::Config,
what: &str,
) -> Option<crate::lance::LanceStore> {
try_open_existing_doc_lance(store, config, what).ok().flatten()
}
fn try_open_existing_doc_lance(
store: &mut Store,
config: &crate::config::Config,
what: &str,
) -> Result<Option<crate::lance::LanceStore>, ()> {
if store.lance.is_none() && !store.lance_dir_exists() {
return Ok(None);
}
let model = &config.documents.embedding_preset;
let dim = match preset_dim(model) {
Ok(dim) => dim,
Err(error) => {
tracing::warn!(?error, preset = %model, "{what}: unknown preset; skipping lance delete");
return Err(());
}
};
match store.lance_or_open(dim, model) {
Ok(lance) => Ok(Some(lance.clone())),
Err(error) => {
tracing::warn!(?error, "{what}: open LanceStore failed; skipping");
Err(())
}
}
}
pub(crate) fn purge_unembedded_documents(
store: &mut Store,
config: &crate::config::Config,
filters: &Filters,
scope: &str,
) -> bool {
let cfg = &config.documents;
let mut by_scope: ahash::AHashMap<String, Vec<String>> = ahash::AHashMap::new();
for key in store.index.doc_files.keys() {
let path = key.to_str_lossy();
if cfg.embed && filters.doc_embed_allowed(&path) {
continue;
}
let doc_scope = doc_scope_for(&path, scope, config).into_owned();
by_scope.entry(doc_scope).or_default().push(path.into_owned());
}
if by_scope.is_empty() {
return true;
}
let lance = match try_open_existing_doc_lance(store, config, "doc embed-policy purge") {
Ok(Some(lance)) => lance,
Ok(None) => return true,
Err(()) => return false,
};
let mut applied = true;
for (doc_scope, paths) in by_scope {
if let Err(error) = lance.delete_documents_for_paths(&doc_scope, &paths) {
tracing::warn!(
paths = paths.len(),
?error,
"doc embed-policy purge failed; search_documents may still return vectors for excluded paths"
);
applied = false;
}
}
applied
}
pub(crate) fn flush_document_batches(store: &mut Store, batches: Vec<PendingDocBatch>, embedding_model: &str) -> usize {
let mut inserted = 0usize;
let Some(dim) = batches
.iter()
.find(|b| b.emit_rows && b.embedding_dim > 0)
.map(|b| b.embedding_dim)
else {
return 0;
};
match preset_dim(embedding_model) {
Ok(expected) if expected != dim => {
tracing::error!(
preset = %embedding_model,
expected,
actual = dim,
"preset/runtime dim mismatch — refusing to write document batch"
);
return 0;
}
Ok(_) => {}
Err(error) => {
tracing::error!(
?error,
preset = %embedding_model,
"unknown embedding preset — refusing to write document batch"
);
return 0;
}
}
let lance = match store.lance_or_open(dim, embedding_model) {
Ok(s) => s.clone(),
Err(error) => {
tracing::error!(?error, "open LanceStore for document batch failed");
return 0;
}
};
for batch in batches {
if !batch.emit_rows {
continue;
}
let doc = match store.read_doc_by_hex(&batch.blob_hash) {
Ok(Some(doc)) => doc,
Ok(None) => {
tracing::warn!(rel = %batch.rel_path, "doc blob missing at flush; skipping vector rows");
continue;
}
Err(error) => {
tracing::warn!(rel = %batch.rel_path, ?error, "re-read doc blob failed; skipping vector rows");
continue;
}
};
let rows = build_doc_rows(doc, &batch.rel_path, &batch.doc_scope);
if rows.is_empty() {
continue;
}
match lance.replace_document(&batch.doc_scope, &batch.rel_path, rows) {
Ok(()) => inserted += 1,
Err(error) => {
tracing::warn!(
rel = %batch.rel_path,
?error,
"lance replace_document failed; document search may be incomplete"
);
}
}
}
inserted
}
pub(crate) fn doc_scope_for<'a>(
rel: &str,
default_scope: &'a str,
config: &crate::config::Config,
) -> std::borrow::Cow<'a, str> {
if !crate::path::is_external_key(rel.as_bytes()) {
return std::borrow::Cow::Borrowed(default_scope);
}
let mut best: Option<String> = None;
for raw_root in &config.scan.extra_roots {
if let Ok(canonical) = raw_root.canonicalize()
&& let Some(prefix) = canonical.to_str()
{
#[cfg(windows)]
let prefix = prefix.replace('\\', "/");
#[cfg(windows)]
let prefix = prefix.as_str();
let prefix = prefix.trim_end_matches('/');
let inside = rel == prefix || rel.strip_prefix(prefix).is_some_and(|rest| rest.starts_with('/'));
if inside && best.as_ref().is_none_or(|b| prefix.len() > b.len()) {
best = Some(prefix.to_string());
}
}
}
if let Some(prefix) = best {
return std::borrow::Cow::Owned(format!("path:{prefix}"));
}
std::borrow::Cow::Owned(format!("path:{rel}"))
}
#[cfg(test)]
#[path = "scanner_docs_security_tests.rs"]
mod security_tests;
#[cfg(test)]
#[path = "scanner_docs_tests.rs"]
mod tests;