mod reader;
#[cfg(test)]
mod tests;
pub use reader::{CsvField, CsvRecordReader, CSV_READ_BUFFER_BYTES};
use hawdb_core::{HawDBError, Result, Value};
use hawdb_plan_cypher::{CsvLocation, LoadCsvSource};
use std::collections::BTreeMap;
use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CsvImportLimits {
pub max_file_bytes: u64,
pub max_record_bytes: usize,
pub max_fields_per_record: usize,
}
impl Default for CsvImportLimits {
fn default() -> Self {
Self {
max_file_bytes: 4 * 1024 * 1024 * 1024,
max_record_bytes: 4 * 1024 * 1024,
max_fields_per_record: 4096,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CsvImportPolicy {
root: PathBuf,
limits: CsvImportLimits,
}
impl CsvImportPolicy {
pub fn new(root: impl Into<PathBuf>, limits: CsvImportLimits) -> Self {
Self {
root: root.into(),
limits,
}
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn limits(&self) -> CsvImportLimits {
self.limits
}
pub fn resolve(&self, location: &str) -> Result<PathBuf> {
let root = std::fs::canonicalize(&self.root).map_err(|error| {
HawDBError::Execution(format!(
"LOAD CSV import root '{}' cannot be resolved: {error}",
self.root.display()
))
})?;
if !root.is_dir() {
return Err(HawDBError::Execution(format!(
"LOAD CSV import root '{}' is not a directory",
root.display()
)));
}
let path = local_path(location)?;
let path = if path.is_absolute() {
path
} else {
root.join(path)
};
let canonical = std::fs::canonicalize(&path).map_err(|error| {
if lexically_outside(&path, &root, &self.root) {
outside_root(location)
} else {
HawDBError::Execution(format!("LOAD CSV cannot access '{location}': {error}"))
}
})?;
if !canonical.starts_with(&root) {
return Err(outside_root(location));
}
Ok(canonical)
}
pub fn open(
&self,
source: &LoadCsvSource,
parameters: &BTreeMap<String, Value>,
) -> Result<CsvRows<File>> {
let location = source_location(source, parameters)?;
let path = self.resolve(location)?;
let inspect = |metadata: std::io::Result<std::fs::Metadata>| {
let metadata = metadata.map_err(|error| {
HawDBError::Execution(format!("LOAD CSV cannot inspect '{location}': {error}"))
})?;
if !metadata.is_file() {
return Err(HawDBError::Execution(format!(
"LOAD CSV source '{location}' is not a regular file"
)));
}
if metadata.len() > self.limits.max_file_bytes {
return Err(HawDBError::Execution(format!(
"LOAD CSV source '{location}' has {} bytes, exceeding max_file_bytes {}",
metadata.len(),
self.limits.max_file_bytes
)));
}
Ok(())
};
inspect(std::fs::metadata(&path))?;
let file = File::open(&path).map_err(|error| {
HawDBError::Execution(format!("LOAD CSV cannot open '{location}': {error}"))
})?;
inspect(file.metadata())?;
CsvRows::new(file, source, self.limits)
}
}
fn outside_root(location: &str) -> HawDBError {
HawDBError::Semantic(format!(
"LOAD CSV source '{location}' is outside the configured import directory"
))
}
fn lexically_outside(path: &Path, canonical_root: &Path, configured_root: &Path) -> bool {
let normalize = |path: &Path| {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
normalized.pop();
}
component => normalized.push(component),
}
}
normalized
};
let path = normalize(path);
let configured = std::path::absolute(configured_root)
.map(|root| normalize(&root))
.ok();
!path.starts_with(canonical_root)
&& configured.is_none_or(|configured| !path.starts_with(configured))
}
pub fn open_load_csv(
policy: Option<&CsvImportPolicy>,
source: &LoadCsvSource,
parameters: &BTreeMap<String, Value>,
) -> Result<CsvRows<File>> {
policy
.ok_or_else(|| {
HawDBError::Semantic(
"LOAD CSV is disabled: set DatabaseConfig::csv_import_root to an import directory"
.to_string(),
)
})?
.open(source, parameters)
}
fn source_location<'a>(
source: &'a LoadCsvSource,
parameters: &'a BTreeMap<String, Value>,
) -> Result<&'a str> {
match &source.location {
CsvLocation::Literal(location) => Ok(location),
CsvLocation::Parameter(name) => match parameters.get(name) {
Some(Value::String(location)) => Ok(location),
Some(_) => Err(HawDBError::Semantic(format!(
"LOAD CSV source parameter '${name}' must be a string"
))),
None => Err(HawDBError::Semantic(format!("missing parameter '${name}'"))),
},
}
}
fn local_path(location: &str) -> Result<PathBuf> {
if location.is_empty() || location.contains('\0') {
return Err(HawDBError::Semantic(
"LOAD CSV source must be a non-empty file path".to_string(),
));
}
let Some(scheme) = url_scheme(location) else {
return Ok(PathBuf::from(location));
};
if !scheme.eq_ignore_ascii_case("file") {
return Err(HawDBError::Semantic(format!(
"LOAD CSV supports only local files; '{scheme}' URLs are not supported"
)));
}
let rest = &location[scheme.len() + 1..];
let path = if let Some(authority_path) = rest.strip_prefix("//") {
let (host, path) = authority_path
.find('/')
.map_or((authority_path, ""), |index| authority_path.split_at(index));
if !host.is_empty() && !host.eq_ignore_ascii_case("localhost") {
return Err(HawDBError::Semantic(format!(
"LOAD CSV file URL host '{host}' is not supported; use file:///path relative to the import directory"
)));
}
path
} else {
rest
};
if path.contains(['?', '#']) {
return Err(HawDBError::Semantic(
"LOAD CSV file URLs must not contain a query or fragment".to_string(),
));
}
if !path.starts_with('/') {
return Err(HawDBError::Semantic(
"LOAD CSV file URLs must have the form file:///path".to_string(),
));
}
let decoded = percent_decode(path)?;
Ok(PathBuf::from(decoded.trim_start_matches('/')))
}
fn url_scheme(location: &str) -> Option<&str> {
let (scheme, _) = location.split_once(':')?;
let mut bytes = scheme.bytes();
let first = bytes.next()?;
(first.is_ascii_alphabetic()
&& scheme.len() > 1
&& bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.')))
.then_some(scheme)
}
fn percent_decode(text: &str) -> Result<String> {
let bytes = text.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' {
let hex = text
.get(index + 1..index + 3)
.and_then(|hex| u8::from_str_radix(hex, 16).ok())
.ok_or_else(|| {
HawDBError::Semantic("LOAD CSV file URL has an invalid percent escape".into())
})?;
if hex == 0 {
return Err(HawDBError::Semantic(
"LOAD CSV file URL must not contain NUL".to_string(),
));
}
decoded.push(hex);
index += 3;
} else {
decoded.push(bytes[index]);
index += 1;
}
}
String::from_utf8(decoded)
.map_err(|_| HawDBError::Semantic("LOAD CSV file URL is not valid UTF-8".to_string()))
}
pub struct CsvRows<R> {
reader: CsvRecordReader<R>,
headers: Option<Vec<String>>,
with_headers: bool,
}
impl<R: Read> CsvRows<R> {
pub fn new(input: R, source: &LoadCsvSource, limits: CsvImportLimits) -> Result<Self> {
Ok(Self {
reader: CsvRecordReader::new(input, source.field_terminator, limits)?,
headers: None,
with_headers: source.with_headers,
})
}
pub fn next_row(&mut self) -> Result<Option<Value>> {
if self.with_headers && self.headers.is_none() {
let Some(header) = self.reader.next_record()? else {
return Ok(None);
};
let mut seen = std::collections::BTreeSet::new();
let headers = header
.into_iter()
.map(|name| {
let name = name.unwrap_or_default();
if !seen.insert(name.clone()) {
return Err(HawDBError::Execution(format!(
"LOAD CSV header repeats column '{name}'"
)));
}
Ok(name)
})
.collect::<Result<Vec<_>>>()?;
self.headers = Some(headers);
}
let Some(record) = self.reader.next_record()? else {
return Ok(None);
};
Ok(Some(match &self.headers {
None => Value::List(record.into_iter().map(field_value).collect()),
Some(headers) => {
let mut fields = record.into_iter();
Value::Map(
headers
.iter()
.map(|name| (name.clone(), fields.next().map_or(Value::Null, field_value)))
.collect(),
)
}
}))
}
pub fn rows_read(&self) -> u64 {
self.reader
.records()
.saturating_sub(u64::from(self.headers.is_some()))
}
}
fn field_value(field: CsvField) -> Value {
field.map_or(Value::Null, Value::String)
}