hawdb_executor/
load_csv.rs1mod reader;
22#[cfg(test)]
23mod tests;
24
25pub use reader::{CsvField, CsvRecordReader, CSV_READ_BUFFER_BYTES};
26
27use hawdb_core::{HawDBError, Result, Value};
28use hawdb_plan_cypher::{CsvLocation, LoadCsvSource};
29use std::collections::BTreeMap;
30use std::fs::File;
31use std::io::Read;
32use std::path::{Path, PathBuf};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct CsvImportLimits {
37 pub max_file_bytes: u64,
40 pub max_record_bytes: usize,
42 pub max_fields_per_record: usize,
44}
45
46impl Default for CsvImportLimits {
47 fn default() -> Self {
48 Self {
49 max_file_bytes: 4 * 1024 * 1024 * 1024,
50 max_record_bytes: 4 * 1024 * 1024,
51 max_fields_per_record: 4096,
52 }
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct CsvImportPolicy {
63 root: PathBuf,
64 limits: CsvImportLimits,
65}
66
67impl CsvImportPolicy {
68 pub fn new(root: impl Into<PathBuf>, limits: CsvImportLimits) -> Self {
69 Self {
70 root: root.into(),
71 limits,
72 }
73 }
74
75 pub fn root(&self) -> &Path {
76 &self.root
77 }
78
79 pub fn limits(&self) -> CsvImportLimits {
80 self.limits
81 }
82
83 pub fn resolve(&self, location: &str) -> Result<PathBuf> {
91 let root = std::fs::canonicalize(&self.root).map_err(|error| {
92 HawDBError::Execution(format!(
93 "LOAD CSV import root '{}' cannot be resolved: {error}",
94 self.root.display()
95 ))
96 })?;
97 if !root.is_dir() {
98 return Err(HawDBError::Execution(format!(
99 "LOAD CSV import root '{}' is not a directory",
100 root.display()
101 )));
102 }
103 let path = local_path(location)?;
104 let path = if path.is_absolute() {
105 path
106 } else {
107 root.join(path)
108 };
109 let canonical = std::fs::canonicalize(&path).map_err(|error| {
110 if lexically_outside(&path, &root, &self.root) {
111 outside_root(location)
112 } else {
113 HawDBError::Execution(format!("LOAD CSV cannot access '{location}': {error}"))
114 }
115 })?;
116 if !canonical.starts_with(&root) {
117 return Err(outside_root(location));
118 }
119 Ok(canonical)
120 }
121
122 pub fn open(
128 &self,
129 source: &LoadCsvSource,
130 parameters: &BTreeMap<String, Value>,
131 ) -> Result<CsvRows<File>> {
132 let location = source_location(source, parameters)?;
133 let path = self.resolve(location)?;
134 let inspect = |metadata: std::io::Result<std::fs::Metadata>| {
135 let metadata = metadata.map_err(|error| {
136 HawDBError::Execution(format!("LOAD CSV cannot inspect '{location}': {error}"))
137 })?;
138 if !metadata.is_file() {
139 return Err(HawDBError::Execution(format!(
140 "LOAD CSV source '{location}' is not a regular file"
141 )));
142 }
143 if metadata.len() > self.limits.max_file_bytes {
144 return Err(HawDBError::Execution(format!(
145 "LOAD CSV source '{location}' has {} bytes, exceeding max_file_bytes {}",
146 metadata.len(),
147 self.limits.max_file_bytes
148 )));
149 }
150 Ok(())
151 };
152 inspect(std::fs::metadata(&path))?;
153 let file = File::open(&path).map_err(|error| {
154 HawDBError::Execution(format!("LOAD CSV cannot open '{location}': {error}"))
155 })?;
156 inspect(file.metadata())?;
157 CsvRows::new(file, source, self.limits)
158 }
159}
160
161fn outside_root(location: &str) -> HawDBError {
162 HawDBError::Semantic(format!(
163 "LOAD CSV source '{location}' is outside the configured import directory"
164 ))
165}
166
167fn lexically_outside(path: &Path, canonical_root: &Path, configured_root: &Path) -> bool {
172 let normalize = |path: &Path| {
173 let mut normalized = PathBuf::new();
174 for component in path.components() {
175 match component {
176 std::path::Component::CurDir => {}
177 std::path::Component::ParentDir => {
178 normalized.pop();
179 }
180 component => normalized.push(component),
181 }
182 }
183 normalized
184 };
185 let path = normalize(path);
186 let configured = std::path::absolute(configured_root)
187 .map(|root| normalize(&root))
188 .ok();
189 !path.starts_with(canonical_root)
190 && configured.is_none_or(|configured| !path.starts_with(configured))
191}
192
193pub fn open_load_csv(
195 policy: Option<&CsvImportPolicy>,
196 source: &LoadCsvSource,
197 parameters: &BTreeMap<String, Value>,
198) -> Result<CsvRows<File>> {
199 policy
200 .ok_or_else(|| {
201 HawDBError::Semantic(
202 "LOAD CSV is disabled: set DatabaseConfig::csv_import_root to an import directory"
203 .to_string(),
204 )
205 })?
206 .open(source, parameters)
207}
208
209fn source_location<'a>(
210 source: &'a LoadCsvSource,
211 parameters: &'a BTreeMap<String, Value>,
212) -> Result<&'a str> {
213 match &source.location {
214 CsvLocation::Literal(location) => Ok(location),
215 CsvLocation::Parameter(name) => match parameters.get(name) {
216 Some(Value::String(location)) => Ok(location),
217 Some(_) => Err(HawDBError::Semantic(format!(
218 "LOAD CSV source parameter '${name}' must be a string"
219 ))),
220 None => Err(HawDBError::Semantic(format!("missing parameter '${name}'"))),
221 },
222 }
223}
224
225fn local_path(location: &str) -> Result<PathBuf> {
229 if location.is_empty() || location.contains('\0') {
230 return Err(HawDBError::Semantic(
231 "LOAD CSV source must be a non-empty file path".to_string(),
232 ));
233 }
234 let Some(scheme) = url_scheme(location) else {
235 return Ok(PathBuf::from(location));
236 };
237 if !scheme.eq_ignore_ascii_case("file") {
238 return Err(HawDBError::Semantic(format!(
239 "LOAD CSV supports only local files; '{scheme}' URLs are not supported"
240 )));
241 }
242 let rest = &location[scheme.len() + 1..];
243 let path = if let Some(authority_path) = rest.strip_prefix("//") {
244 let (host, path) = authority_path
245 .find('/')
246 .map_or((authority_path, ""), |index| authority_path.split_at(index));
247 if !host.is_empty() && !host.eq_ignore_ascii_case("localhost") {
248 return Err(HawDBError::Semantic(format!(
249 "LOAD CSV file URL host '{host}' is not supported; use file:///path relative to the import directory"
250 )));
251 }
252 path
253 } else {
254 rest
255 };
256 if path.contains(['?', '#']) {
257 return Err(HawDBError::Semantic(
258 "LOAD CSV file URLs must not contain a query or fragment".to_string(),
259 ));
260 }
261 if !path.starts_with('/') {
262 return Err(HawDBError::Semantic(
263 "LOAD CSV file URLs must have the form file:///path".to_string(),
264 ));
265 }
266 let decoded = percent_decode(path)?;
267 Ok(PathBuf::from(decoded.trim_start_matches('/')))
270}
271
272fn url_scheme(location: &str) -> Option<&str> {
275 let (scheme, _) = location.split_once(':')?;
276 let mut bytes = scheme.bytes();
277 let first = bytes.next()?;
278 (first.is_ascii_alphabetic()
279 && scheme.len() > 1
280 && bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.')))
281 .then_some(scheme)
282}
283
284fn percent_decode(text: &str) -> Result<String> {
285 let bytes = text.as_bytes();
286 let mut decoded = Vec::with_capacity(bytes.len());
287 let mut index = 0;
288 while index < bytes.len() {
289 if bytes[index] == b'%' {
290 let hex = text
291 .get(index + 1..index + 3)
292 .and_then(|hex| u8::from_str_radix(hex, 16).ok())
293 .ok_or_else(|| {
294 HawDBError::Semantic("LOAD CSV file URL has an invalid percent escape".into())
295 })?;
296 if hex == 0 {
297 return Err(HawDBError::Semantic(
298 "LOAD CSV file URL must not contain NUL".to_string(),
299 ));
300 }
301 decoded.push(hex);
302 index += 3;
303 } else {
304 decoded.push(bytes[index]);
305 index += 1;
306 }
307 }
308 String::from_utf8(decoded)
309 .map_err(|_| HawDBError::Semantic("LOAD CSV file URL is not valid UTF-8".to_string()))
310}
311
312pub struct CsvRows<R> {
319 reader: CsvRecordReader<R>,
320 headers: Option<Vec<String>>,
321 with_headers: bool,
322}
323
324impl<R: Read> CsvRows<R> {
325 pub fn new(input: R, source: &LoadCsvSource, limits: CsvImportLimits) -> Result<Self> {
326 Ok(Self {
327 reader: CsvRecordReader::new(input, source.field_terminator, limits)?,
328 headers: None,
329 with_headers: source.with_headers,
330 })
331 }
332
333 pub fn next_row(&mut self) -> Result<Option<Value>> {
334 if self.with_headers && self.headers.is_none() {
335 let Some(header) = self.reader.next_record()? else {
336 return Ok(None);
337 };
338 let mut seen = std::collections::BTreeSet::new();
339 let headers = header
340 .into_iter()
341 .map(|name| {
342 let name = name.unwrap_or_default();
343 if !seen.insert(name.clone()) {
344 return Err(HawDBError::Execution(format!(
345 "LOAD CSV header repeats column '{name}'"
346 )));
347 }
348 Ok(name)
349 })
350 .collect::<Result<Vec<_>>>()?;
351 self.headers = Some(headers);
352 }
353 let Some(record) = self.reader.next_record()? else {
354 return Ok(None);
355 };
356 Ok(Some(match &self.headers {
357 None => Value::List(record.into_iter().map(field_value).collect()),
358 Some(headers) => {
359 let mut fields = record.into_iter();
360 Value::Map(
361 headers
362 .iter()
363 .map(|name| (name.clone(), fields.next().map_or(Value::Null, field_value)))
364 .collect(),
365 )
366 }
367 }))
368 }
369
370 pub fn rows_read(&self) -> u64 {
372 self.reader
373 .records()
374 .saturating_sub(u64::from(self.headers.is_some()))
375 }
376}
377
378fn field_value(field: CsvField) -> Value {
379 field.map_or(Value::Null, Value::String)
380}