mod csv;
mod docx;
mod html;
mod json;
mod markdown;
mod pdf;
mod sitemap;
mod text;
mod web_scraper;
pub use csv::CSVLoader;
pub use docx::DocxLoader;
pub use html::HTMLLoader;
pub use json::JSONLoader;
pub use markdown::MarkdownLoader;
pub use pdf::PDFLoader;
pub use sitemap::SitemapLoader;
pub use text::TextLoader;
pub use web_scraper::WebScraperLoader;
use async_trait::async_trait;
use futures_util::{Stream, StreamExt};
use lc_vector_stores::Document;
use std::time::Duration;
const MAX_HTTP_BODY_BYTES: usize = 1024 * 1024;
pub(crate) async fn guarded_fetch(
url: &str,
timeout: Duration,
) -> Result<(String, String), LoaderError> {
let resp = lc_core::ssrf::guarded_get(url, true, Some(timeout))
.await
.map_err(|e| LoaderError::Other(format!("HTTP request failed {}: {}", url, e)))?;
let final_url = resp.url().as_str().to_string();
let status = resp.status();
if !status.is_success() {
return Err(LoaderError::Other(format!(
"HTTP error {}: {}",
url, status
)));
}
let body = read_capped_body(resp.bytes_stream(), url, MAX_HTTP_BODY_BYTES).await?;
let text = String::from_utf8(body)
.map_err(|_| LoaderError::Other(format!("response for {} is not valid UTF-8", url)))?;
Ok((final_url, text))
}
pub(crate) async fn read_capped_body<S, T, E>(
mut stream: S,
url: &str,
max_bytes: usize,
) -> Result<Vec<u8>, LoaderError>
where
S: Stream<Item = Result<T, E>> + Unpin,
T: AsRef<[u8]>,
E: std::fmt::Display,
{
let mut body = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk
.map_err(|e| LoaderError::Other(format!("failed to read response {}: {}", url, e)))?;
body.extend_from_slice(chunk.as_ref());
if body.len() > max_bytes {
return Err(LoaderError::Other(format!(
"response for {} exceeds the {:.0} KiB size limit",
url,
max_bytes / 1024
)));
}
}
Ok(body)
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum LoaderError {
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("CSV parse error: {0}")]
CsvError(String),
#[error("PDF parse error: {0}")]
PdfError(String),
#[error("JSON parse error: {0}")]
JsonError(String),
#[error("unknown error: {0}")]
Other(String),
}
impl From<pdf_extract::Error> for LoaderError {
fn from(err: pdf_extract::Error) -> Self {
LoaderError::PdfError(err.to_string())
}
}
#[async_trait]
pub trait DocumentLoader: Send + Sync {
async fn load(&self) -> Result<Vec<Document>, LoaderError>;
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn guarded_fetch_blocks_private_loopback() {
let err = guarded_fetch("http://127.0.0.1/sitemap.xml", Duration::from_secs(5))
.await
.unwrap_err();
assert!(
err.to_string().contains("SSRF"),
"expected an SSRF rejection, got: {err}"
);
}
#[tokio::test]
async fn guarded_fetch_blocks_link_local() {
let err = guarded_fetch(
"http://[::ffff:169.254.169.254]/latest",
Duration::from_secs(5),
)
.await
.unwrap_err();
assert!(
err.to_string().contains("SSRF"),
"expected an SSRF rejection, got: {err}"
);
}
#[tokio::test]
async fn read_capped_body_accepts_body_exactly_at_the_limit() {
use futures_util::stream;
let half = vec![b'a'; MAX_HTTP_BODY_BYTES / 2];
let chunks: Vec<Result<Vec<u8>, std::convert::Infallible>> =
vec![Ok(half.clone()), Ok(half)];
let body = read_capped_body(
stream::iter(chunks),
"http://example.com/x",
MAX_HTTP_BODY_BYTES,
)
.await
.expect("exactly 1 MiB must be accepted");
assert_eq!(body.len(), MAX_HTTP_BODY_BYTES);
}
#[tokio::test]
async fn read_capped_body_rejects_oversize_without_polling_the_rest() {
use futures_util::stream;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let poison_pulled = Arc::new(AtomicUsize::new(0));
let counter = poison_pulled.clone();
let chunks: Vec<Result<Vec<u8>, std::convert::Infallible>> = vec![
Ok(vec![b'a'; 600 * 1024]),
Ok(vec![b'b'; 600 * 1024]),
Ok(vec![b'c'; 1024]),
];
let s = stream::unfold(0usize, move |idx| {
let chunks = chunks.clone();
let counter = counter.clone();
async move {
if idx >= chunks.len() {
return None;
}
if idx == 2 {
counter.fetch_add(1, Ordering::SeqCst);
}
let item = chunks[idx].clone();
Some((item, idx + 1))
}
});
let s = Box::pin(s);
let err = read_capped_body(s, "http://example.com/huge", MAX_HTTP_BODY_BYTES)
.await
.unwrap_err();
assert!(
err.to_string().contains("1024 KiB size limit"),
"expected a size-limit error, got: {err}"
);
assert_eq!(
poison_pulled.load(Ordering::SeqCst),
0,
"stream must not be polled for chunks past the cap (unbounded buffering)"
);
}
#[tokio::test]
async fn read_capped_body_rejects_non_utf8_inside_the_limit() {
use futures_util::stream;
let chunks: Vec<Result<Vec<u8>, std::convert::Infallible>> =
vec![Ok(b"abc".to_vec()), Ok(vec![0xff, 0xfe])];
let body = read_capped_body(stream::iter(chunks), "http://example.com/bin", 64)
.await
.expect("small body accepted");
let err = String::from_utf8(body).unwrap_err();
assert!(err.to_string().contains("invalid utf-8"));
}
}