1pub mod bundle;
8
9pub use bundle::FileBundle;
10
11use std::collections::HashSet;
12use std::future::Future;
13use std::path::PathBuf;
14use std::pin::Pin;
15use std::str::FromStr;
16use std::task::{Context, Poll};
17use std::time::Duration;
18
19use async_trait::async_trait;
20use bytes::Bytes;
21use dashmap::DashMap;
22use futures::StreamExt;
23use regex::Regex;
24use tokio::fs;
25use tokio::fs::OpenOptions;
26use tokio::io;
27use tokio::io::AsyncWriteExt;
28use tokio::time;
29use tokio_util::io::ReaderStream;
30use tower::Service;
31use tracing::{debug, warn};
32
33use camel_component_api::{
34 Body, BoxProcessor, CamelError, Exchange, Message, StreamBody, StreamMetadata,
35};
36use camel_component_api::{Component, Consumer, ConsumerContext, Endpoint, ProducerContext};
37use camel_component_api::{UriConfig, parse_uri};
38use camel_language_api::Language;
39use camel_language_simple::SimpleLanguage;
40
41struct ModificationDetectingStream<S> {
49 inner: S,
50 file_path: PathBuf,
51 initial_size: u64,
52 initial_mtime: Option<std::time::SystemTime>,
53 checked: bool,
54}
55
56impl<S> ModificationDetectingStream<S> {
57 fn new(
58 inner: S,
59 file_path: PathBuf,
60 initial_size: u64,
61 initial_mtime: Option<std::time::SystemTime>,
62 ) -> Self {
63 Self {
64 inner,
65 file_path,
66 initial_size,
67 initial_mtime,
68 checked: false,
69 }
70 }
71}
72
73impl<S> futures::Stream for ModificationDetectingStream<S>
74where
75 S: futures::Stream<Item = Result<Bytes, CamelError>> + Unpin,
76{
77 type Item = Result<Bytes, CamelError>;
78
79 fn poll_next(
80 mut self: std::pin::Pin<&mut Self>,
81 cx: &mut std::task::Context<'_>,
82 ) -> std::task::Poll<Option<Self::Item>> {
83 let inner_poll = std::pin::Pin::new(&mut self.inner).poll_next(cx);
85
86 match inner_poll {
87 std::task::Poll::Ready(None) => {
88 if !self.checked {
90 self.checked = true;
91 if let Ok(current_meta) = std::fs::metadata(&self.file_path) {
92 let current_size = current_meta.len();
93 let current_mtime = current_meta.modified().ok();
94
95 if current_size != self.initial_size || current_mtime != self.initial_mtime
96 {
97 return std::task::Poll::Ready(Some(Err(CamelError::ProcessorError(
98 format!("file modified during read: {}", self.file_path.display()),
99 ))));
100 }
101 }
102 }
103 std::task::Poll::Ready(None)
104 }
105 other => other,
106 }
107 }
108}
109
110struct TempFileGuard {
119 path: PathBuf,
120 disarm: bool,
121}
122
123impl TempFileGuard {
124 fn new(path: PathBuf) -> Self {
125 Self {
126 path,
127 disarm: false,
128 }
129 }
130
131 fn disarm(&mut self) {
133 self.disarm = true;
134 }
135}
136
137impl Drop for TempFileGuard {
138 fn drop(&mut self) {
139 if !self.disarm {
140 let _ = std::fs::remove_file(&self.path);
142 }
143 }
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
152pub enum FileExistStrategy {
153 #[default]
155 Override,
156 Append,
158 Fail,
160 Ignore,
162}
163
164impl FromStr for FileExistStrategy {
165 type Err = String;
166
167 fn from_str(s: &str) -> Result<Self, Self::Err> {
168 match s {
169 "Override" | "override" => Ok(FileExistStrategy::Override),
170 "Append" | "append" => Ok(FileExistStrategy::Append),
171 "Fail" | "fail" => Ok(FileExistStrategy::Fail),
172 "Ignore" | "ignore" => Ok(FileExistStrategy::Ignore),
173 other => Err(format!("unknown FileExistStrategy: {other}")),
174 }
175 }
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
179pub enum ReadLockStrategy {
180 #[default]
181 None,
182 InProcess,
183 Rename,
184}
185
186impl FromStr for ReadLockStrategy {
187 type Err = String;
188
189 fn from_str(s: &str) -> Result<Self, Self::Err> {
190 match s {
191 "None" | "none" => Ok(Self::None),
192 "InProcess" | "inProcess" | "inprocess" => Ok(Self::InProcess),
193 "Rename" | "rename" => Ok(Self::Rename),
194 other => Err(format!("unknown ReadLockStrategy: {other}")),
195 }
196 }
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
200pub enum IdempotentKey {
201 #[default]
202 None,
203 FileName,
204 FilePath,
205 FileSize,
206 Digest,
208}
209
210impl FromStr for IdempotentKey {
211 type Err = String;
212
213 fn from_str(s: &str) -> Result<Self, Self::Err> {
214 match s {
215 "None" | "none" => Ok(Self::None),
216 "FileName" | "fileName" | "filename" => Ok(Self::FileName),
217 "FilePath" | "filePath" | "filepath" => Ok(Self::FilePath),
218 "FileSize" | "fileSize" | "filesize" => Ok(Self::FileSize),
219 "Digest" | "digest" => Ok(Self::Digest),
220 other => Err(format!("unknown IdempotentKey: {other}")),
221 }
222 }
223}
224
225#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
233#[serde(default)]
234pub struct FileGlobalConfig {
235 pub delay_ms: u64,
236 pub initial_delay_ms: u64,
237 pub read_timeout_ms: u64,
238 pub write_timeout_ms: u64,
239}
240
241impl Default for FileGlobalConfig {
242 fn default() -> Self {
243 Self {
244 delay_ms: 500,
245 initial_delay_ms: 1_000,
246 read_timeout_ms: 30_000,
247 write_timeout_ms: 30_000,
248 }
249 }
250}
251
252impl FileGlobalConfig {
253 pub fn new() -> Self {
254 Self::default()
255 }
256 pub fn with_delay_ms(mut self, v: u64) -> Self {
257 self.delay_ms = v;
258 self
259 }
260 pub fn with_initial_delay_ms(mut self, v: u64) -> Self {
261 self.initial_delay_ms = v;
262 self
263 }
264 pub fn with_read_timeout_ms(mut self, v: u64) -> Self {
265 self.read_timeout_ms = v;
266 self
267 }
268 pub fn with_write_timeout_ms(mut self, v: u64) -> Self {
269 self.write_timeout_ms = v;
270 self
271 }
272}
273
274#[derive(Debug, Clone)]
302pub struct FileConfig {
303 pub directory: String,
305
306 #[allow(dead_code)]
308 delay_ms: u64,
309
310 pub delay: Duration,
312
313 #[allow(dead_code)]
315 initial_delay_ms: u64,
316
317 pub initial_delay: Duration,
319
320 pub noop: bool,
322
323 pub delete: bool,
325
326 move_to: Option<String>,
329
330 pub file_name: Option<String>,
332
333 pub include: Option<String>,
335
336 pub exclude: Option<String>,
338
339 pub recursive: bool,
341
342 pub file_exist: FileExistStrategy,
344
345 pub read_lock_strategy: ReadLockStrategy,
347
348 pub idempotent_key: IdempotentKey,
350
351 pub done_file_name: Option<String>,
353
354 pub charset: Option<String>,
356
357 pub temp_prefix: Option<String>,
359
360 pub auto_create: bool,
362
363 pub starting_directory_must_exist: bool,
365
366 #[allow(dead_code)]
368 read_timeout_ms: u64,
369
370 pub read_timeout: Duration,
372
373 #[allow(dead_code)]
375 write_timeout_ms: u64,
376
377 pub write_timeout: Duration,
379}
380
381impl UriConfig for FileConfig {
382 fn scheme() -> &'static str {
383 "file"
384 }
385
386 fn from_uri(uri: &str) -> Result<Self, CamelError> {
387 let parts = parse_uri(uri)?;
388 Self::from_components(parts)
389 }
390
391 fn from_components(parts: camel_component_api::UriComponents) -> Result<Self, CamelError> {
392 if parts.scheme != Self::scheme() {
393 return Err(CamelError::InvalidUri(format!(
394 "unsupported scheme '{}', expected '{}'",
395 parts.scheme,
396 Self::scheme()
397 )));
398 }
399
400 fn parse_bool_param(
401 params: &std::collections::HashMap<String, String>,
402 key: &str,
403 default: bool,
404 ) -> Result<bool, CamelError> {
405 match params.get(key) {
406 Some(v) => match v.to_lowercase().as_str() {
407 "true" | "1" | "yes" => Ok(true),
408 "false" | "0" | "no" => Ok(false),
409 _ => Err(CamelError::InvalidUri(format!(
410 "invalid value for {key}: invalid boolean value: '{v}'"
411 ))),
412 },
413 None => Ok(default),
414 }
415 }
416
417 fn parse_u64_param(
418 params: &std::collections::HashMap<String, String>,
419 key: &str,
420 default: u64,
421 ) -> Result<u64, CamelError> {
422 match params.get(key) {
423 Some(v) => v
424 .parse::<u64>()
425 .map_err(|e| CamelError::InvalidUri(format!("invalid value for {key}: {e}"))),
426 None => Ok(default),
427 }
428 }
429
430 fn parse_enum_param<T: FromStr>(
431 params: &std::collections::HashMap<String, String>,
432 key: &str,
433 default: &str,
434 ) -> Result<T, CamelError>
435 where
436 T::Err: std::fmt::Display,
437 {
438 let raw = params.get(key).map(String::as_str).unwrap_or(default);
439 raw.parse::<T>().map_err(|e| {
440 CamelError::InvalidUri(format!("invalid value for parameter '{key}': {e}"))
441 })
442 }
443
444 let params = &parts.params;
445 let delay_ms = parse_u64_param(params, "delay", 500)?;
446 let initial_delay_ms = parse_u64_param(params, "initialDelay", 1000)?;
447 let read_timeout_ms = parse_u64_param(params, "readTimeout", 30_000)?;
448 let write_timeout_ms = parse_u64_param(params, "writeTimeout", 30_000)?;
449
450 let cfg = Self {
451 directory: parts.path,
452 delay_ms,
453 delay: Duration::from_millis(delay_ms),
454 initial_delay_ms,
455 initial_delay: Duration::from_millis(initial_delay_ms),
456 noop: parse_bool_param(params, "noop", false)?,
457 delete: parse_bool_param(params, "delete", false)?,
458 move_to: params.get("move").cloned(),
459 file_name: params.get("fileName").cloned(),
460 include: params.get("include").cloned(),
461 exclude: params.get("exclude").cloned(),
462 recursive: parse_bool_param(params, "recursive", false)?,
463 file_exist: parse_enum_param(params, "fileExist", "Override")?,
464 read_lock_strategy: parse_enum_param(params, "readLock", "None")?,
465 idempotent_key: parse_enum_param(params, "idempotentKey", "None")?,
466 done_file_name: params.get("doneFileName").cloned(),
467 charset: params.get("charset").cloned(),
468 temp_prefix: params.get("tempPrefix").cloned(),
469 auto_create: parse_bool_param(params, "autoCreate", true)?,
470 starting_directory_must_exist: parse_bool_param(
471 params,
472 "startingDirectoryMustExist",
473 false,
474 )?,
475 read_timeout_ms,
476 read_timeout: Duration::from_millis(read_timeout_ms),
477 write_timeout_ms,
478 write_timeout: Duration::from_millis(write_timeout_ms),
479 };
480
481 cfg.validate()
482 }
483
484 fn validate(self) -> Result<Self, CamelError> {
485 if let Some(ref move_to) = self.move_to
487 && path_contains_traversal(move_to)
488 {
489 return Err(CamelError::Config(format!(
490 "move_to contains path traversal component: {move_to}"
491 )));
492 }
493
494 if let Some(ref move_to) = self.move_to
495 && std::path::Path::new(move_to).is_absolute()
496 {
497 return Err(CamelError::InvalidUri(format!(
498 "move_to must be relative path within base directory: {move_to}"
499 )));
500 }
501
502 if let Some(ref temp_prefix) = self.temp_prefix
504 && path_contains_traversal(temp_prefix)
505 {
506 return Err(CamelError::Config(format!(
507 "temp_prefix contains path traversal component: {temp_prefix}"
508 )));
509 }
510
511 if let Some(ref temp_prefix) = self.temp_prefix
512 && !is_valid_temp_prefix(temp_prefix)
513 {
514 return Err(CamelError::Config(
515 "temp_prefix must be plain filename prefix (no path separators, absolute paths, or null bytes)".into(),
516 ));
517 }
518
519 if let Some(ref file_name) = self.file_name {
521 if file_name.is_empty() {
522 return Err(CamelError::Config("file_name must not be empty".into()));
523 }
524 if file_name.contains('\0') {
526 return Err(CamelError::Config(
527 "file_name must not contain null bytes".into(),
528 ));
529 }
530 }
531
532 if self.starting_directory_must_exist {
534 let dir_path = std::path::Path::new(&self.directory);
535 if !dir_path.exists() {
536 return Err(CamelError::Config(format!(
537 "starting directory does not exist: {}",
538 self.directory
539 )));
540 }
541 }
542
543 let move_to = if self.noop || self.delete {
547 None
548 } else {
549 Some(self.move_to.unwrap_or_else(|| ".camel".to_string()))
550 };
551
552 Ok(Self { move_to, ..self })
553 }
554}
555
556impl FileConfig {
557 pub fn apply_global_defaults(&mut self, global: &FileGlobalConfig) {
565 if self.delay == Duration::from_millis(500) {
566 self.delay = Duration::from_millis(global.delay_ms);
567 }
568 if self.initial_delay == Duration::from_millis(1_000) {
569 self.initial_delay = Duration::from_millis(global.initial_delay_ms);
570 }
571 if self.read_timeout == Duration::from_millis(30_000) {
572 self.read_timeout = Duration::from_millis(global.read_timeout_ms);
573 }
574 if self.write_timeout == Duration::from_millis(30_000) {
575 self.write_timeout = Duration::from_millis(global.write_timeout_ms);
576 }
577 }
578}
579
580pub struct FileComponent {
585 config: Option<FileGlobalConfig>,
586}
587
588impl FileComponent {
589 pub fn new() -> Self {
590 Self { config: None }
591 }
592
593 pub fn with_config(config: FileGlobalConfig) -> Self {
594 Self {
595 config: Some(config),
596 }
597 }
598
599 pub fn with_optional_config(config: Option<FileGlobalConfig>) -> Self {
600 Self { config }
601 }
602}
603
604impl Default for FileComponent {
605 fn default() -> Self {
606 Self::new()
607 }
608}
609
610impl Component for FileComponent {
611 fn scheme(&self) -> &str {
612 "file"
613 }
614
615 fn create_endpoint(
616 &self,
617 uri: &str,
618 _ctx: &dyn camel_component_api::ComponentContext,
619 ) -> Result<Box<dyn Endpoint>, CamelError> {
620 let mut config = FileConfig::from_uri(uri)?;
621 if let Some(ref global_config) = self.config {
622 config.apply_global_defaults(global_config);
623 }
624 Ok(Box::new(FileEndpoint {
625 uri: uri.to_string(),
626 config,
627 in_process_locks: std::sync::Arc::new(DashMap::new()),
628 idempotent_repo: std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new())),
629 }))
630 }
631}
632
633struct FileEndpoint {
638 uri: String,
639 config: FileConfig,
640 in_process_locks: std::sync::Arc<DashMap<PathBuf, ()>>,
641 idempotent_repo: std::sync::Arc<tokio::sync::Mutex<HashSet<String>>>,
642}
643
644impl Endpoint for FileEndpoint {
645 fn uri(&self) -> &str {
646 &self.uri
647 }
648
649 fn create_consumer(&self) -> Result<Box<dyn Consumer>, CamelError> {
650 Ok(Box::new(FileConsumer {
651 config: self.config.clone(),
652 seen: HashSet::new(),
653 in_process_locks: self.in_process_locks.clone(),
654 idempotent_repo: self.idempotent_repo.clone(),
655 }))
656 }
657
658 fn create_producer(&self, _ctx: &ProducerContext) -> Result<BoxProcessor, CamelError> {
659 Ok(BoxProcessor::new(FileProducer {
660 config: self.config.clone(),
661 }))
662 }
663}
664
665struct FileConsumer {
670 config: FileConfig,
671 seen: HashSet<PathBuf>,
672 in_process_locks: std::sync::Arc<DashMap<PathBuf, ()>>,
673 idempotent_repo: std::sync::Arc<tokio::sync::Mutex<HashSet<String>>>,
674}
675
676#[async_trait]
677impl Consumer for FileConsumer {
678 async fn start(&mut self, context: ConsumerContext) -> Result<(), CamelError> {
679 let config = self.config.clone();
680
681 let include_re = config
682 .include
683 .as_ref()
684 .map(|p| Regex::new(p))
685 .transpose()
686 .map_err(|e| CamelError::InvalidUri(format!("invalid include regex: {e}")))?;
687 let exclude_re = config
688 .exclude
689 .as_ref()
690 .map(|p| Regex::new(p))
691 .transpose()
692 .map_err(|e| CamelError::InvalidUri(format!("invalid exclude regex: {e}")))?;
693
694 if !config.initial_delay.is_zero() {
695 tokio::select! {
696 _ = time::sleep(config.initial_delay) => {}
697 _ = context.cancelled() => {
698 debug!(directory = config.directory, "File consumer cancelled during initial delay");
699 return Ok(());
700 }
701 }
702 }
703
704 let mut interval = time::interval(config.delay);
705
706 loop {
707 tokio::select! {
708 _ = context.cancelled() => {
709 debug!(directory = config.directory, "File consumer received cancellation, stopping");
710 break;
711 }
712 _ = interval.tick() => {
713 if let Err(e) = poll_directory(
714 &config,
715 &context,
716 &include_re,
717 &exclude_re,
718 &mut self.seen,
719 &self.in_process_locks,
720 &self.idempotent_repo,
721 ).await {
722 warn!(directory = config.directory, error = %e, "Error polling directory");
723 }
724 }
725 }
726 }
727
728 Ok(())
729 }
730
731 async fn stop(&mut self) -> Result<(), CamelError> {
732 Ok(())
733 }
734}
735
736async fn poll_directory(
737 config: &FileConfig,
738 context: &ConsumerContext,
739 include_re: &Option<Regex>,
740 exclude_re: &Option<Regex>,
741 seen: &mut HashSet<PathBuf>,
742 in_process_locks: &std::sync::Arc<DashMap<PathBuf, ()>>,
743 idempotent_repo: &std::sync::Arc<tokio::sync::Mutex<HashSet<String>>>,
744) -> Result<(), CamelError> {
745 let base_path = std::path::Path::new(&config.directory);
746
747 let files = list_files(base_path, config.recursive).await?;
748
749 for file_path in files {
750 let file_name = file_path
751 .file_name()
752 .and_then(|n| n.to_str())
753 .unwrap_or_default()
754 .to_string();
755
756 if let Some(ref target_name) = config.file_name
757 && file_name != *target_name
758 {
759 continue;
760 }
761
762 if let Some(re) = include_re
763 && !re.is_match(&file_name)
764 {
765 continue;
766 }
767
768 if let Some(re) = exclude_re
769 && re.is_match(&file_name)
770 {
771 continue;
772 }
773
774 if let Some(ref move_dir) = config.move_to
775 && file_path.starts_with(base_path.join(move_dir))
776 {
777 continue;
778 }
779
780 if config.noop && seen.contains(&file_path) {
782 continue;
783 }
784
785 let lock = match config.read_lock_strategy {
786 ReadLockStrategy::None => FileReadLock::None(file_path.clone()),
787 ReadLockStrategy::InProcess => {
788 if in_process_locks.insert(file_path.clone(), ()).is_some() {
789 continue;
790 }
791 FileReadLock::InProcess(file_path.clone())
792 }
793 ReadLockStrategy::Rename => {
794 let lock_path = file_path.with_file_name(format!("{file_name}.camel-lock"));
795 if fs::rename(&file_path, &lock_path).await.is_err() {
796 continue;
797 }
798 FileReadLock::Rename {
799 original: file_path.clone(),
800 locked: lock_path,
801 }
802 }
803 };
804
805 let active_path = lock.active_path();
806
807 let (file, metadata) = match tokio::time::timeout(config.read_timeout, async {
808 let f = fs::File::open(active_path).await?;
809 let m = f.metadata().await?;
810 Ok::<_, std::io::Error>((f, m))
811 })
812 .await
813 {
814 Ok(Ok((f, m))) => (f, Some(m)),
815 Ok(Err(e)) => {
816 warn!(
817 file = %file_path.display(),
818 error = %e,
819 "Failed to open file"
820 );
821 if let FileReadLock::Rename { original, locked } = &lock {
822 let _ = fs::rename(locked, original).await;
823 }
824 if let FileReadLock::InProcess(path) = &lock {
825 in_process_locks.remove(path);
826 }
827 continue;
828 }
829 Err(_) => {
830 warn!(
831 file = %file_path.display(),
832 timeout_ms = config.read_timeout.as_millis(),
833 "Timeout opening file"
834 );
835 if let FileReadLock::Rename { original, locked } = &lock {
836 let _ = fs::rename(locked, original).await;
837 }
838 if let FileReadLock::InProcess(path) = &lock {
839 in_process_locks.remove(path);
840 }
841 continue;
842 }
843 };
844
845 if let Some(key) = metadata
846 .as_ref()
847 .and_then(|m| build_idempotent_key(config.idempotent_key, active_path, &file_name, m))
848 {
849 let mut repo = idempotent_repo.lock().await;
850 if repo.contains(&key) {
851 if let FileReadLock::Rename { original, locked } = &lock {
852 let _ = fs::rename(locked, original).await;
853 }
854 if let FileReadLock::InProcess(path) = &lock {
855 in_process_locks.remove(path);
856 }
857 continue;
858 }
859 repo.insert(key);
860 }
861
862 let file_len = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
863 let initial_mtime = metadata.as_ref().and_then(|m| m.modified().ok());
864
865 let raw_stream = ReaderStream::new(file).map(|res| res.map_err(CamelError::from));
866 let stream = ModificationDetectingStream::new(
867 raw_stream,
868 active_path.to_path_buf(),
869 file_len,
870 initial_mtime,
871 );
872
873 let last_modified = initial_mtime
874 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
875 .map(|d| d.as_millis() as u64)
876 .unwrap_or(0);
877
878 let relative_path = file_path
879 .strip_prefix(base_path)
880 .unwrap_or(&file_path)
881 .to_string_lossy()
882 .to_string();
883
884 let absolute_path = file_path
885 .canonicalize()
886 .unwrap_or_else(|_| file_path.clone())
887 .to_string_lossy()
888 .to_string();
889
890 let body = Body::Stream(StreamBody {
891 stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
892 metadata: StreamMetadata {
893 size_hint: Some(file_len),
894 content_type: None,
895 origin: Some(absolute_path.clone()),
896 },
897 });
898
899 let mut exchange = Exchange::new(Message::new(body));
900 exchange
901 .input
902 .set_header("CamelFileName", serde_json::Value::String(relative_path));
903 exchange.input.set_header(
904 "CamelFileNameOnly",
905 serde_json::Value::String(file_name.clone()),
906 );
907 exchange.input.set_header(
908 "CamelFileAbsolutePath",
909 serde_json::Value::String(absolute_path),
910 );
911 exchange.input.set_header(
912 "CamelFileLength",
913 serde_json::Value::Number(file_len.into()),
914 );
915 exchange.input.set_header(
916 "CamelFileLastModified",
917 serde_json::Value::Number(last_modified.into()),
918 );
919
920 debug!(
921 file = %file_path.display(),
922 correlation_id = %exchange.correlation_id(),
923 "Processing file"
924 );
925
926 if context.send(exchange).await.is_err() {
927 if let FileReadLock::Rename { original, locked } = &lock {
928 let _ = fs::rename(locked, original).await;
929 }
930 if let FileReadLock::InProcess(path) = &lock {
931 in_process_locks.remove(path);
932 }
933 break;
934 }
935
936 if config.noop {
937 seen.insert(file_path.clone());
938 }
939
940 if !config.noop
941 && !config.delete
942 && let Some(ref move_dir) = config.move_to
943 {
944 if std::path::Path::new(move_dir).is_absolute() {
945 warn!(move_to = %move_dir, "Ignoring absolute move_to path outside base directory");
946 if let FileReadLock::Rename { original, locked } = &lock {
947 let _ = fs::rename(locked, original).await;
948 }
949 if let FileReadLock::InProcess(path) = &lock {
950 in_process_locks.remove(path);
951 }
952 continue;
953 }
954
955 let canonical_base = match fs::canonicalize(base_path).await {
956 Ok(path) => path,
957 Err(e) => {
958 warn!(base = %base_path.display(), error = %e, "Failed to canonicalize base path for move_to validation");
959 if let FileReadLock::Rename { original, locked } = &lock {
960 let _ = fs::rename(locked, original).await;
961 }
962 if let FileReadLock::InProcess(path) = &lock {
963 in_process_locks.remove(path);
964 }
965 continue;
966 }
967 };
968
969 let target_dir = base_path.join(move_dir);
970 let resolved_target_dir = if target_dir.exists() {
971 match fs::canonicalize(&target_dir).await {
972 Ok(path) => path,
973 Err(e) => {
974 warn!(dir = %target_dir.display(), error = %e, "Failed to canonicalize move target directory");
975 if let FileReadLock::Rename { original, locked } = &lock {
976 let _ = fs::rename(locked, original).await;
977 }
978 if let FileReadLock::InProcess(path) = &lock {
979 in_process_locks.remove(path);
980 }
981 continue;
982 }
983 }
984 } else {
985 canonical_base.join(move_dir)
986 };
987
988 if !resolved_target_dir.starts_with(&canonical_base) {
989 warn!(dir = %target_dir.display(), base = %canonical_base.display(), "Skipping move_to outside base directory");
990 if let FileReadLock::Rename { original, locked } = &lock {
991 let _ = fs::rename(locked, original).await;
992 }
993 if let FileReadLock::InProcess(path) = &lock {
994 in_process_locks.remove(path);
995 }
996 continue;
997 }
998
999 if let Err(e) = fs::create_dir_all(&target_dir).await {
1000 warn!(dir = %target_dir.display(), error = %e, "Failed to create move directory");
1001 if let FileReadLock::Rename { original, locked } = &lock {
1002 let _ = fs::rename(locked, original).await;
1003 }
1004 if let FileReadLock::InProcess(path) = &lock {
1005 in_process_locks.remove(path);
1006 }
1007 continue;
1008 }
1009 }
1010
1011 finalize_locked_file(config, &lock, base_path, &file_name).await;
1012
1013 if let FileReadLock::InProcess(path) = &lock {
1014 in_process_locks.remove(path);
1015 }
1016 }
1017
1018 Ok(())
1019}
1020
1021enum FileReadLock {
1022 None(PathBuf),
1023 InProcess(PathBuf),
1024 Rename { original: PathBuf, locked: PathBuf },
1025}
1026
1027impl FileReadLock {
1028 fn active_path(&self) -> &std::path::Path {
1029 match self {
1030 Self::None(path) | Self::InProcess(path) => path,
1031 Self::Rename { locked, .. } => locked,
1032 }
1033 }
1034}
1035
1036fn build_idempotent_key(
1037 strategy: IdempotentKey,
1038 active_path: &std::path::Path,
1039 file_name: &str,
1040 metadata: &std::fs::Metadata,
1041) -> Option<String> {
1042 match strategy {
1043 IdempotentKey::None => None,
1044 IdempotentKey::FileName => Some(file_name.to_string()),
1045 IdempotentKey::FilePath => Some(active_path.to_string_lossy().to_string()),
1046 IdempotentKey::FileSize => Some(metadata.len().to_string()),
1047 IdempotentKey::Digest => metadata
1048 .modified()
1049 .ok()
1050 .and_then(|mtime| mtime.duration_since(std::time::UNIX_EPOCH).ok())
1051 .map(|ts| format!("{}:{}", active_path.to_string_lossy(), ts.as_nanos())),
1052 }
1053}
1054
1055async fn finalize_locked_file(
1056 config: &FileConfig,
1057 lock: &FileReadLock,
1058 base_path: &std::path::Path,
1059 file_name: &str,
1060) {
1061 match lock {
1062 FileReadLock::None(path) | FileReadLock::InProcess(path) => {
1063 if config.noop {
1064 return;
1065 }
1066 if config.delete {
1067 if let Err(e) = fs::remove_file(path).await {
1068 warn!(file = %path.display(), error = %e, "Failed to delete file");
1069 }
1070 } else if let Some(ref move_dir) = config.move_to {
1071 let target_path = base_path.join(move_dir).join(file_name);
1072 if let Err(e) = fs::rename(path, &target_path).await {
1073 warn!(from = %path.display(), to = %target_path.display(), error = %e, "Failed to move file");
1074 }
1075 }
1076 }
1077 FileReadLock::Rename { original, locked } => {
1078 if config.noop {
1079 if let Err(e) = fs::rename(locked, original).await {
1080 warn!(from = %locked.display(), to = %original.display(), error = %e, "Failed to unlock file");
1081 }
1082 } else if config.delete {
1083 if let Err(e) = fs::remove_file(locked).await {
1084 warn!(file = %locked.display(), error = %e, "Failed to delete file");
1085 }
1086 } else if let Some(ref move_dir) = config.move_to {
1087 let target_path = base_path.join(move_dir).join(file_name);
1088 if let Err(e) = fs::rename(locked, &target_path).await {
1089 warn!(from = %locked.display(), to = %target_path.display(), error = %e, "Failed to move locked file");
1090 }
1091 }
1092 }
1093 }
1094}
1095
1096async fn list_files(
1097 dir: &std::path::Path,
1098 recursive: bool,
1099) -> Result<Vec<std::path::PathBuf>, CamelError> {
1100 async fn list_files_inner(
1101 dir: &std::path::Path,
1102 recursive: bool,
1103 visited: &mut HashSet<PathBuf>,
1104 ) -> Result<Vec<std::path::PathBuf>, CamelError> {
1105 let mut files = Vec::new();
1106 let canonical_dir = fs::canonicalize(dir).await.map_err(CamelError::from)?;
1107
1108 if !visited.insert(canonical_dir) {
1109 return Ok(files);
1110 }
1111
1112 let mut read_dir = fs::read_dir(dir).await.map_err(CamelError::from)?;
1113 while let Some(entry) = read_dir.next_entry().await.map_err(CamelError::from)? {
1114 let path = entry.path();
1115 let metadata = fs::symlink_metadata(&path)
1116 .await
1117 .map_err(CamelError::from)?;
1118 let file_type = metadata.file_type();
1119
1120 if file_type.is_file() {
1121 files.push(path);
1122 } else if file_type.is_dir() && recursive {
1123 let mut sub_files = Box::pin(list_files_inner(&path, true, visited)).await?;
1124 files.append(&mut sub_files);
1125 } else if file_type.is_symlink() {
1126 continue;
1128 }
1129 }
1130
1131 Ok(files)
1132 }
1133
1134 let mut visited = HashSet::new();
1135 let mut files = list_files_inner(dir, recursive, &mut visited).await?;
1136
1137 files.sort();
1138 Ok(files)
1139}
1140
1141fn path_contains_traversal(path: &str) -> bool {
1147 std::path::Path::new(path)
1148 .components()
1149 .any(|c| matches!(c, std::path::Component::ParentDir))
1150}
1151
1152fn is_valid_temp_prefix(prefix: &str) -> bool {
1153 !prefix.contains('\0')
1154 && !std::path::Path::new(prefix).is_absolute()
1155 && !prefix.contains(std::path::MAIN_SEPARATOR)
1156 && !prefix.contains('/')
1157 && !prefix.contains('\\')
1158}
1159
1160fn validate_path_is_within_base(
1161 base_dir: &std::path::Path,
1162 target_path: &std::path::Path,
1163) -> Result<(), CamelError> {
1164 if base_dir.exists() {
1168 let canonical_base = base_dir.canonicalize().map_err(|e| {
1169 CamelError::ProcessorError(format!("Cannot canonicalize base directory: {}", e))
1170 })?;
1171
1172 let canonical_target = if target_path.exists() {
1173 target_path.canonicalize().map_err(|e| {
1174 CamelError::ProcessorError(format!("Cannot canonicalize target path: {}", e))
1175 })?
1176 } else if let Some(parent) = target_path.parent() {
1177 if parent.exists() {
1178 let canonical_parent = parent.canonicalize().map_err(|e| {
1179 CamelError::ProcessorError(format!(
1180 "Cannot canonicalize parent directory: {}",
1181 e
1182 ))
1183 })?;
1184 if let Some(filename) = target_path.file_name() {
1185 canonical_parent.join(filename)
1186 } else {
1187 return Err(CamelError::ProcessorError(
1188 "Invalid target path: no filename".to_string(),
1189 ));
1190 }
1191 } else {
1192 let rel = target_path.strip_prefix(base_dir).map_err(|_| {
1194 CamelError::ProcessorError(format!(
1195 "Path '{}' is not under base '{}'",
1196 target_path.display(),
1197 base_dir.display()
1198 ))
1199 })?;
1200 if path_contains_traversal(&rel.to_string_lossy()) {
1201 return Err(CamelError::ProcessorError(format!(
1202 "Path '{}' contains directory traversal",
1203 target_path.display()
1204 )));
1205 }
1206 return Ok(());
1207 }
1208 } else {
1209 return Err(CamelError::ProcessorError(
1210 "Invalid target path: no parent directory".to_string(),
1211 ));
1212 };
1213
1214 if !canonical_target.starts_with(&canonical_base) {
1215 return Err(CamelError::ProcessorError(format!(
1216 "Path '{}' is outside base directory '{}'",
1217 canonical_target.display(),
1218 canonical_base.display()
1219 )));
1220 }
1221 } else {
1222 let rel = target_path.strip_prefix(base_dir).map_err(|_| {
1225 CamelError::ProcessorError(format!(
1226 "Path '{}' is not under base '{}'",
1227 target_path.display(),
1228 base_dir.display()
1229 ))
1230 })?;
1231 let rel_str = rel.to_string_lossy();
1232 if path_contains_traversal(&rel_str) {
1233 return Err(CamelError::ProcessorError(format!(
1234 "Path '{}' contains directory traversal",
1235 target_path.display()
1236 )));
1237 }
1238 }
1239
1240 Ok(())
1241}
1242
1243#[derive(Clone)]
1248struct FileProducer {
1249 config: FileConfig,
1250}
1251
1252impl FileProducer {
1253 async fn resolve_filename(
1254 exchange: &Exchange,
1255 config: &FileConfig,
1256 ) -> Result<String, CamelError> {
1257 let raw = if let Some(name) = exchange
1258 .input
1259 .header("CamelFileName")
1260 .and_then(|v| v.as_str())
1261 {
1262 Some(name.to_string())
1263 } else {
1264 config.file_name.clone()
1265 };
1266
1267 match raw {
1268 Some(name) if name.contains("${") => {
1269 let lang = SimpleLanguage::new();
1270 let expr = lang.create_expression(&name).map_err(|e| {
1271 CamelError::ProcessorError(format!(
1272 "cannot parse fileName expression '{}': {e}",
1273 name
1274 ))
1275 })?;
1276 let val = expr.evaluate(exchange).await.map_err(|e| {
1277 CamelError::ProcessorError(format!(
1278 "cannot evaluate fileName expression '{}': {e}",
1279 name
1280 ))
1281 })?;
1282 match val {
1283 serde_json::Value::String(s) => Ok(s),
1284 other => Ok(other.to_string()),
1285 }
1286 }
1287 Some(name) => Ok(name),
1288 None => Err(CamelError::ProcessorError(
1289 "No filename specified: set CamelFileName header or fileName option".to_string(),
1290 )),
1291 }
1292 }
1293}
1294
1295impl Service<Exchange> for FileProducer {
1296 type Response = Exchange;
1297 type Error = CamelError;
1298 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
1299
1300 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
1301 Poll::Ready(Ok(()))
1302 }
1303
1304 fn call(&mut self, mut exchange: Exchange) -> Self::Future {
1305 let config = self.config.clone();
1306
1307 Box::pin(async move {
1308 let file_name = FileProducer::resolve_filename(&exchange, &config).await?;
1309 let body = exchange.input.body.clone();
1310
1311 let dir_path = std::path::Path::new(&config.directory);
1312 let target_path = dir_path.join(&file_name);
1313
1314 validate_path_is_within_base(dir_path, &target_path)?;
1316
1317 if config.auto_create
1319 && let Some(parent) = target_path.parent()
1320 {
1321 tokio::time::timeout(config.write_timeout, fs::create_dir_all(parent))
1322 .await
1323 .map_err(|_| CamelError::ProcessorError("Timeout creating directories".into()))?
1324 .map_err(CamelError::from)?;
1325 }
1326
1327 match config.file_exist {
1329 FileExistStrategy::Fail => {
1330 let mut file = tokio::time::timeout(
1331 config.write_timeout,
1332 OpenOptions::new()
1333 .write(true)
1334 .create_new(true)
1335 .open(&target_path),
1336 )
1337 .await
1338 .map_err(|_| {
1339 CamelError::ProcessorError("Timeout opening file with create_new".into())
1340 })?
1341 .map_err(CamelError::from)?;
1342
1343 write_body_with_charset(body, &config.charset, &mut file, config.write_timeout)
1344 .await?;
1345 file.flush().await.map_err(CamelError::from)?;
1346 }
1347 FileExistStrategy::Ignore if target_path.exists() => return Ok(exchange),
1348 FileExistStrategy::Append => {
1349 let mut file = tokio::time::timeout(
1351 config.write_timeout,
1352 OpenOptions::new()
1353 .append(true)
1354 .create(true)
1355 .open(&target_path),
1356 )
1357 .await
1358 .map_err(|_| {
1359 CamelError::ProcessorError("Timeout opening file for append".into())
1360 })?
1361 .map_err(CamelError::from)?;
1362
1363 write_body_with_charset(body, &config.charset, &mut file, config.write_timeout)
1364 .await?;
1365
1366 file.flush().await.map_err(CamelError::from)?;
1367 }
1368 _ => {
1369 let temp_name = if let Some(ref prefix) = config.temp_prefix {
1371 format!("{prefix}{file_name}")
1372 } else {
1373 format!(".tmp.{file_name}")
1374 };
1375 let temp_path = dir_path.join(&temp_name);
1376
1377 let mut guard = TempFileGuard::new(temp_path.clone());
1379
1380 let mut file =
1382 tokio::time::timeout(config.write_timeout, fs::File::create(&temp_path))
1383 .await
1384 .map_err(|_| {
1385 CamelError::ProcessorError("Timeout creating temp file".into())
1386 })?
1387 .map_err(CamelError::from)?;
1388
1389 let copy_result = write_body_with_charset(
1390 body,
1391 &config.charset,
1392 &mut file,
1393 config.write_timeout,
1394 )
1395 .await;
1396
1397 let _ = file.flush().await;
1399
1400 copy_result?;
1401
1402 let rename_result = tokio::time::timeout(
1404 config.write_timeout,
1405 fs::rename(&temp_path, &target_path),
1406 )
1407 .await;
1408
1409 match rename_result {
1410 Ok(Ok(_)) => {
1411 guard.disarm();
1413 }
1414 Ok(Err(e)) => {
1415 return Err(CamelError::from(e));
1417 }
1418 Err(_) => {
1419 return Err(CamelError::ProcessorError("Timeout renaming file".into()));
1421 }
1422 }
1423 }
1424 }
1425
1426 if let Some(done_pattern) = &config.done_file_name {
1427 let done_name = done_pattern.replace("${file:name}", &file_name);
1428 tokio::time::timeout(
1429 config.write_timeout,
1430 fs::write(dir_path.join(done_name), []),
1431 )
1432 .await
1433 .map_err(|_| CamelError::ProcessorError("Timeout creating done file".into()))?
1434 .map_err(CamelError::from)?;
1435 }
1436
1437 let abs_path = target_path
1439 .canonicalize()
1440 .unwrap_or_else(|_| target_path.clone())
1441 .to_string_lossy()
1442 .to_string();
1443 exchange
1444 .input
1445 .set_header("CamelFileNameProduced", serde_json::Value::String(abs_path));
1446
1447 debug!(
1448 file = %target_path.display(),
1449 correlation_id = %exchange.correlation_id(),
1450 "File written"
1451 );
1452
1453 Ok(exchange)
1454 })
1455 }
1456}
1457
1458async fn write_body_with_charset(
1459 body: Body,
1460 charset: &Option<String>,
1461 file: &mut fs::File,
1462 timeout: Duration,
1463) -> Result<(), CamelError> {
1464 match body {
1465 Body::Text(text) | Body::Xml(text) => {
1466 let bytes = encode_text_by_charset(&text, charset)?;
1467 tokio::time::timeout(timeout, file.write_all(&bytes))
1468 .await
1469 .map_err(|_| CamelError::ProcessorError("Timeout writing file".into()))?
1470 .map_err(CamelError::from)?;
1471 Ok(())
1472 }
1473 other => {
1474 let mut reader = other.into_async_read()?;
1475 tokio::time::timeout(timeout, io::copy(&mut reader, file))
1476 .await
1477 .map_err(|_| CamelError::ProcessorError("Timeout writing to file".into()))?
1478 .map_err(|e| CamelError::ProcessorError(e.to_string()))?;
1479 Ok(())
1480 }
1481 }
1482}
1483
1484fn encode_text_by_charset(text: &str, charset: &Option<String>) -> Result<Vec<u8>, CamelError> {
1485 let Some(charset) = charset.as_ref() else {
1486 return Ok(text.as_bytes().to_vec());
1487 };
1488
1489 if charset.eq_ignore_ascii_case("utf-8") {
1490 return Ok(text.as_bytes().to_vec());
1491 }
1492
1493 if charset.eq_ignore_ascii_case("iso-8859-1") {
1494 let mut out = Vec::with_capacity(text.len());
1495 for ch in text.chars() {
1496 let code = ch as u32;
1497 if code > 0xFF {
1498 return Err(CamelError::ProcessorError(format!(
1499 "character '{ch}' cannot be encoded as ISO-8859-1"
1500 )));
1501 }
1502 out.push(code as u8);
1503 }
1504 return Ok(out);
1505 }
1506
1507 Err(CamelError::Config(format!(
1508 "unsupported charset '{charset}', supported: UTF-8, ISO-8859-1"
1509 )))
1510}
1511
1512#[cfg(test)]
1513mod tests {
1514 use super::*;
1515 use bytes::Bytes;
1516 use camel_component_api::NoOpComponentContext;
1517 use std::time::Duration;
1518 use tokio_util::sync::CancellationToken;
1519
1520 fn test_producer_ctx() -> ProducerContext {
1521 ProducerContext::new()
1522 }
1523
1524 #[test]
1525 fn test_file_config_defaults() {
1526 let config = FileConfig::from_uri("file:/tmp/inbox").unwrap();
1527 assert_eq!(config.directory, "/tmp/inbox");
1528 assert_eq!(config.delay, Duration::from_millis(500));
1529 assert_eq!(config.initial_delay, Duration::from_millis(1000));
1530 assert!(!config.noop);
1531 assert!(!config.delete);
1532 assert_eq!(config.move_to, Some(".camel".to_string()));
1533 assert!(config.file_name.is_none());
1534 assert!(config.include.is_none());
1535 assert!(config.exclude.is_none());
1536 assert!(!config.recursive);
1537 assert_eq!(config.file_exist, FileExistStrategy::Override);
1538 assert!(config.temp_prefix.is_none());
1539 assert!(config.auto_create);
1540 assert_eq!(config.read_timeout, Duration::from_secs(30));
1542 assert_eq!(config.write_timeout, Duration::from_secs(30));
1543 }
1544
1545 #[test]
1546 fn test_file_config_consumer_options() {
1547 let config = FileConfig::from_uri(
1548 "file:/data/input?delay=1000&initialDelay=2000&noop=true&recursive=true&include=.*\\.csv"
1549 ).unwrap();
1550 assert_eq!(config.directory, "/data/input");
1551 assert_eq!(config.delay, Duration::from_millis(1000));
1552 assert_eq!(config.initial_delay, Duration::from_millis(2000));
1553 assert!(config.noop);
1554 assert!(config.recursive);
1555 assert_eq!(config.include, Some(".*\\.csv".to_string()));
1556 }
1557
1558 #[test]
1559 fn test_file_config_producer_options() {
1560 let config = FileConfig::from_uri(
1561 "file:/data/output?fileExist=Append&tempPrefix=.tmp&autoCreate=false&fileName=out.txt",
1562 )
1563 .unwrap();
1564 assert_eq!(config.file_exist, FileExistStrategy::Append);
1565 assert_eq!(config.temp_prefix, Some(".tmp".to_string()));
1566 assert!(!config.auto_create);
1567 assert_eq!(config.file_name, Some("out.txt".to_string()));
1568 }
1569
1570 #[test]
1571 fn test_file_config_delete_mode() {
1572 let config = FileConfig::from_uri("file:/tmp/inbox?delete=true").unwrap();
1573 assert!(config.delete);
1574 assert!(config.move_to.is_none());
1575 }
1576
1577 #[test]
1578 fn test_file_config_noop_mode() {
1579 let config = FileConfig::from_uri("file:/tmp/inbox?noop=true").unwrap();
1580 assert!(config.noop);
1581 assert!(config.move_to.is_none());
1582 }
1583
1584 #[test]
1585 fn test_file_config_wrong_scheme() {
1586 let result = FileConfig::from_uri("timer:tick");
1587 assert!(result.is_err());
1588 }
1589
1590 #[test]
1591 fn test_file_component_scheme() {
1592 let component = FileComponent::new();
1593 assert_eq!(component.scheme(), "file");
1594 }
1595
1596 #[test]
1597 fn test_file_component_creates_endpoint() {
1598 let component = FileComponent::new();
1599 let ctx = NoOpComponentContext;
1600 let endpoint = component.create_endpoint("file:/tmp/test", &ctx);
1601 assert!(endpoint.is_ok());
1602 }
1603
1604 #[tokio::test]
1609 async fn test_file_consumer_reads_files() {
1610 let dir = tempfile::tempdir().unwrap();
1611 let dir_path = dir.path().to_str().unwrap();
1612
1613 std::fs::write(dir.path().join("test1.txt"), "hello").unwrap();
1614 std::fs::write(dir.path().join("test2.txt"), "world").unwrap();
1615
1616 let component = FileComponent::new();
1617 let ctx = NoOpComponentContext;
1618 let endpoint = component
1619 .create_endpoint(
1620 &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100"),
1621 &ctx,
1622 )
1623 .unwrap();
1624 let mut consumer = endpoint.create_consumer().unwrap();
1625
1626 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1627 let token = CancellationToken::new();
1628 let ctx = ConsumerContext::new(tx, token.clone());
1629
1630 tokio::spawn(async move {
1631 consumer.start(ctx).await.unwrap();
1632 });
1633
1634 let mut received = Vec::new();
1635 let timeout = tokio::time::timeout(Duration::from_secs(2), async {
1636 while let Some(envelope) = rx.recv().await {
1637 received.push(envelope.exchange);
1638 if received.len() == 2 {
1639 break;
1640 }
1641 }
1642 })
1643 .await;
1644 token.cancel();
1645
1646 assert!(timeout.is_ok(), "Should have received 2 exchanges");
1647 assert_eq!(received.len(), 2);
1648
1649 for ex in &received {
1650 assert!(ex.input.header("CamelFileName").is_some());
1651 assert!(ex.input.header("CamelFileNameOnly").is_some());
1652 assert!(ex.input.header("CamelFileAbsolutePath").is_some());
1653 assert!(ex.input.header("CamelFileLength").is_some());
1654 assert!(ex.input.header("CamelFileLastModified").is_some());
1655 }
1656 }
1657
1658 #[tokio::test]
1659 async fn noop_second_poll_does_not_re_emit_seen_files() {
1660 let dir = tempfile::tempdir().unwrap();
1661 let file_path = dir.path().join("test.txt");
1662 tokio::fs::write(&file_path, b"hello").await.unwrap();
1663
1664 let uri = format!(
1665 "file:{}?noop=true&initialDelay=0&delay=50",
1666 dir.path().display()
1667 );
1668 let config = FileConfig::from_uri(&uri).unwrap();
1669 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1670 let token = CancellationToken::new();
1671 let ctx = ConsumerContext::new(tx, token);
1672
1673 let include_re = None;
1674 let exclude_re = None;
1675 let mut seen = std::collections::HashSet::new();
1676 let in_process_locks = std::sync::Arc::new(DashMap::new());
1677 let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
1678
1679 poll_directory(
1680 &config,
1681 &ctx,
1682 &include_re,
1683 &exclude_re,
1684 &mut seen,
1685 &in_process_locks,
1686 &idempotent_repo,
1687 )
1688 .await
1689 .unwrap();
1690 assert!(rx.try_recv().is_ok(), "first poll should emit file");
1691 assert!(rx.try_recv().is_err(), "should only emit once");
1692
1693 poll_directory(
1694 &config,
1695 &ctx,
1696 &include_re,
1697 &exclude_re,
1698 &mut seen,
1699 &in_process_locks,
1700 &idempotent_repo,
1701 )
1702 .await
1703 .unwrap();
1704 assert!(
1705 rx.try_recv().is_err(),
1706 "second poll should not re-emit seen file"
1707 );
1708 }
1709
1710 #[tokio::test]
1711 async fn noop_new_files_picked_up_after_first_poll() {
1712 let dir = tempfile::tempdir().unwrap();
1713 let file1 = dir.path().join("a.txt");
1714 tokio::fs::write(&file1, b"a").await.unwrap();
1715
1716 let uri = format!(
1717 "file:{}?noop=true&initialDelay=0&delay=50",
1718 dir.path().display()
1719 );
1720 let config = FileConfig::from_uri(&uri).unwrap();
1721 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1722 let token = CancellationToken::new();
1723 let ctx = ConsumerContext::new(tx, token);
1724
1725 let include_re = None;
1726 let exclude_re = None;
1727 let mut seen = std::collections::HashSet::new();
1728 let in_process_locks = std::sync::Arc::new(DashMap::new());
1729 let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
1730
1731 poll_directory(
1732 &config,
1733 &ctx,
1734 &include_re,
1735 &exclude_re,
1736 &mut seen,
1737 &in_process_locks,
1738 &idempotent_repo,
1739 )
1740 .await
1741 .unwrap();
1742 let _ = rx.try_recv();
1743
1744 let file2 = dir.path().join("b.txt");
1745 tokio::fs::write(&file2, b"b").await.unwrap();
1746
1747 poll_directory(
1748 &config,
1749 &ctx,
1750 &include_re,
1751 &exclude_re,
1752 &mut seen,
1753 &in_process_locks,
1754 &idempotent_repo,
1755 )
1756 .await
1757 .unwrap();
1758 assert!(
1759 rx.try_recv().is_ok(),
1760 "b.txt should be emitted on second poll"
1761 );
1762 assert!(rx.try_recv().is_err(), "a.txt should not be re-emitted");
1763 }
1764
1765 #[tokio::test]
1766 async fn test_file_consumer_include_filter() {
1767 let dir = tempfile::tempdir().unwrap();
1768 let dir_path = dir.path().to_str().unwrap();
1769
1770 std::fs::write(dir.path().join("data.csv"), "a,b,c").unwrap();
1771 std::fs::write(dir.path().join("readme.txt"), "hello").unwrap();
1772
1773 let component = FileComponent::new();
1774 let ctx = NoOpComponentContext;
1775 let endpoint = component
1776 .create_endpoint(
1777 &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&include=.*\\.csv"),
1778 &ctx,
1779 )
1780 .unwrap();
1781 let mut consumer = endpoint.create_consumer().unwrap();
1782
1783 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1784 let token = CancellationToken::new();
1785 let ctx = ConsumerContext::new(tx, token.clone());
1786
1787 tokio::spawn(async move {
1788 consumer.start(ctx).await.unwrap();
1789 });
1790
1791 let mut received = Vec::new();
1792 let _ = tokio::time::timeout(Duration::from_millis(500), async {
1793 while let Some(envelope) = rx.recv().await {
1794 received.push(envelope.exchange);
1795 if received.len() == 1 {
1796 break;
1797 }
1798 }
1799 })
1800 .await;
1801 token.cancel();
1802
1803 assert_eq!(received.len(), 1);
1804 let name = received[0]
1805 .input
1806 .header("CamelFileNameOnly")
1807 .and_then(|v| v.as_str())
1808 .unwrap();
1809 assert_eq!(name, "data.csv");
1810 }
1811
1812 #[tokio::test]
1813 async fn test_file_consumer_delete_mode() {
1814 let dir = tempfile::tempdir().unwrap();
1815 let dir_path = dir.path().to_str().unwrap();
1816
1817 std::fs::write(dir.path().join("deleteme.txt"), "bye").unwrap();
1818
1819 let component = FileComponent::new();
1820 let ctx = NoOpComponentContext;
1821 let endpoint = component
1822 .create_endpoint(
1823 &format!("file:{dir_path}?delete=true&initialDelay=0&delay=100"),
1824 &ctx,
1825 )
1826 .unwrap();
1827 let mut consumer = endpoint.create_consumer().unwrap();
1828
1829 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1830 let token = CancellationToken::new();
1831 let ctx = ConsumerContext::new(tx, token.clone());
1832
1833 tokio::spawn(async move {
1834 consumer.start(ctx).await.unwrap();
1835 });
1836
1837 let _ = tokio::time::timeout(Duration::from_millis(500), async { rx.recv().await }).await;
1838 token.cancel();
1839
1840 tokio::time::sleep(Duration::from_millis(100)).await;
1841
1842 assert!(
1843 !dir.path().join("deleteme.txt").exists(),
1844 "File should be deleted"
1845 );
1846 }
1847
1848 #[tokio::test]
1849 async fn test_file_consumer_move_mode() {
1850 let dir = tempfile::tempdir().unwrap();
1851 tokio::fs::write(dir.path().join("moveme.txt"), b"data")
1852 .await
1853 .unwrap();
1854
1855 let uri = format!("file:{}?initialDelay=0&delay=50", dir.path().display());
1856 let config = FileConfig::from_uri(&uri).unwrap();
1857 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1858 let token = CancellationToken::new();
1859 let ctx = ConsumerContext::new(tx, token);
1860
1861 let include_re = None;
1862 let exclude_re = None;
1863 let mut seen = std::collections::HashSet::new();
1864 let in_process_locks = std::sync::Arc::new(DashMap::new());
1865 let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
1866
1867 poll_directory(
1868 &config,
1869 &ctx,
1870 &include_re,
1871 &exclude_re,
1872 &mut seen,
1873 &in_process_locks,
1874 &idempotent_repo,
1875 )
1876 .await
1877 .unwrap();
1878
1879 let ex = rx.try_recv().expect("should receive exchange");
1880 drop(ex);
1881
1882 assert!(
1883 !dir.path().join("moveme.txt").exists(),
1884 "Original file should be gone"
1885 );
1886 assert!(
1887 dir.path().join(".camel").join("moveme.txt").exists(),
1888 "File should be in .camel/"
1889 );
1890 }
1891
1892 #[tokio::test]
1893 async fn test_file_consumer_respects_cancellation() {
1894 let dir = tempfile::tempdir().unwrap();
1895 let dir_path = dir.path().to_str().unwrap();
1896
1897 let component = FileComponent::new();
1898 let ctx = NoOpComponentContext;
1899 let endpoint = component
1900 .create_endpoint(&format!("file:{dir_path}?initialDelay=0&delay=50"), &ctx)
1901 .unwrap();
1902 let mut consumer = endpoint.create_consumer().unwrap();
1903
1904 let (tx, _rx) = tokio::sync::mpsc::channel(16);
1905 let token = CancellationToken::new();
1906 let ctx = ConsumerContext::new(tx, token.clone());
1907
1908 let handle = tokio::spawn(async move {
1909 consumer.start(ctx).await.unwrap();
1910 });
1911
1912 tokio::time::sleep(Duration::from_millis(150)).await;
1913 token.cancel();
1914
1915 let result = tokio::time::timeout(Duration::from_secs(1), handle).await;
1916 assert!(
1917 result.is_ok(),
1918 "Consumer should have stopped after cancellation"
1919 );
1920 }
1921
1922 #[tokio::test]
1927 async fn test_file_producer_writes_file() {
1928 use tower::ServiceExt;
1929
1930 let dir = tempfile::tempdir().unwrap();
1931 let dir_path = dir.path().to_str().unwrap();
1932
1933 let component = FileComponent::new();
1934 let ctx = NoOpComponentContext;
1935 let endpoint = component
1936 .create_endpoint(&format!("file:{dir_path}"), &ctx)
1937 .unwrap();
1938 let ctx = test_producer_ctx();
1939 let producer = endpoint.create_producer(&ctx).unwrap();
1940
1941 let mut exchange = Exchange::new(Message::new("file content"));
1942 exchange.input.set_header(
1943 "CamelFileName",
1944 serde_json::Value::String("output.txt".to_string()),
1945 );
1946
1947 let result = producer.oneshot(exchange).await.unwrap();
1948
1949 let content = std::fs::read_to_string(dir.path().join("output.txt")).unwrap();
1950 assert_eq!(content, "file content");
1951
1952 assert!(result.input.header("CamelFileNameProduced").is_some());
1953 }
1954
1955 #[tokio::test]
1956 async fn test_file_producer_auto_create_dirs() {
1957 use tower::ServiceExt;
1958
1959 let dir = tempfile::tempdir().unwrap();
1960 let dir_path = dir.path().to_str().unwrap();
1961
1962 let component = FileComponent::new();
1963 let ctx = NoOpComponentContext;
1964 let endpoint = component
1965 .create_endpoint(&format!("file:{dir_path}/sub/dir"), &ctx)
1966 .unwrap();
1967 let ctx = test_producer_ctx();
1968 let producer = endpoint.create_producer(&ctx).unwrap();
1969
1970 let mut exchange = Exchange::new(Message::new("nested"));
1971 exchange.input.set_header(
1972 "CamelFileName",
1973 serde_json::Value::String("file.txt".to_string()),
1974 );
1975
1976 producer.oneshot(exchange).await.unwrap();
1977
1978 assert!(dir.path().join("sub/dir/file.txt").exists());
1979 }
1980
1981 #[tokio::test]
1982 async fn test_file_producer_file_exist_fail() {
1983 use tower::ServiceExt;
1984
1985 let dir = tempfile::tempdir().unwrap();
1986 let dir_path = dir.path().to_str().unwrap();
1987
1988 std::fs::write(dir.path().join("existing.txt"), "old").unwrap();
1989
1990 let component = FileComponent::new();
1991 let ctx = NoOpComponentContext;
1992 let endpoint = component
1993 .create_endpoint(&format!("file:{dir_path}?fileExist=Fail"), &ctx)
1994 .unwrap();
1995 let ctx = test_producer_ctx();
1996 let producer = endpoint.create_producer(&ctx).unwrap();
1997
1998 let mut exchange = Exchange::new(Message::new("new"));
1999 exchange.input.set_header(
2000 "CamelFileName",
2001 serde_json::Value::String("existing.txt".to_string()),
2002 );
2003
2004 let result = producer.oneshot(exchange).await;
2005 assert!(
2006 result.is_err(),
2007 "Should fail when file exists with Fail strategy"
2008 );
2009 }
2010
2011 #[tokio::test]
2012 async fn test_file_producer_file_exist_append() {
2013 use tower::ServiceExt;
2014
2015 let dir = tempfile::tempdir().unwrap();
2016 let dir_path = dir.path().to_str().unwrap();
2017
2018 std::fs::write(dir.path().join("append.txt"), "old").unwrap();
2019
2020 let component = FileComponent::new();
2021 let ctx = NoOpComponentContext;
2022 let endpoint = component
2023 .create_endpoint(&format!("file:{dir_path}?fileExist=Append"), &ctx)
2024 .unwrap();
2025 let ctx = test_producer_ctx();
2026 let producer = endpoint.create_producer(&ctx).unwrap();
2027
2028 let mut exchange = Exchange::new(Message::new("new"));
2029 exchange.input.set_header(
2030 "CamelFileName",
2031 serde_json::Value::String("append.txt".to_string()),
2032 );
2033
2034 producer.oneshot(exchange).await.unwrap();
2035
2036 let content = std::fs::read_to_string(dir.path().join("append.txt")).unwrap();
2037 assert_eq!(content, "oldnew");
2038 }
2039
2040 #[tokio::test]
2041 async fn test_file_producer_temp_prefix() {
2042 use tower::ServiceExt;
2043
2044 let dir = tempfile::tempdir().unwrap();
2045 let dir_path = dir.path().to_str().unwrap();
2046
2047 let component = FileComponent::new();
2048 let ctx = NoOpComponentContext;
2049 let endpoint = component
2050 .create_endpoint(&format!("file:{dir_path}?tempPrefix=.tmp"), &ctx)
2051 .unwrap();
2052 let ctx = test_producer_ctx();
2053 let producer = endpoint.create_producer(&ctx).unwrap();
2054
2055 let mut exchange = Exchange::new(Message::new("atomic write"));
2056 exchange.input.set_header(
2057 "CamelFileName",
2058 serde_json::Value::String("final.txt".to_string()),
2059 );
2060
2061 producer.oneshot(exchange).await.unwrap();
2062
2063 assert!(dir.path().join("final.txt").exists());
2064 assert!(!dir.path().join(".tmpfinal.txt").exists());
2065 let content = std::fs::read_to_string(dir.path().join("final.txt")).unwrap();
2066 assert_eq!(content, "atomic write");
2067 }
2068
2069 #[tokio::test]
2070 async fn test_file_producer_uses_filename_option() {
2071 use tower::ServiceExt;
2072
2073 let dir = tempfile::tempdir().unwrap();
2074 let dir_path = dir.path().to_str().unwrap();
2075
2076 let component = FileComponent::new();
2077 let ctx = NoOpComponentContext;
2078 let endpoint = component
2079 .create_endpoint(&format!("file:{dir_path}?fileName=fixed.txt"), &ctx)
2080 .unwrap();
2081 let ctx = test_producer_ctx();
2082 let producer = endpoint.create_producer(&ctx).unwrap();
2083
2084 let exchange = Exchange::new(Message::new("content"));
2085
2086 producer.oneshot(exchange).await.unwrap();
2087 assert!(dir.path().join("fixed.txt").exists());
2088 }
2089
2090 #[tokio::test]
2091 async fn test_file_producer_no_filename_errors() {
2092 use tower::ServiceExt;
2093
2094 let dir = tempfile::tempdir().unwrap();
2095 let dir_path = dir.path().to_str().unwrap();
2096
2097 let component = FileComponent::new();
2098 let ctx = NoOpComponentContext;
2099 let endpoint = component
2100 .create_endpoint(&format!("file:{dir_path}"), &ctx)
2101 .unwrap();
2102 let ctx = test_producer_ctx();
2103 let producer = endpoint.create_producer(&ctx).unwrap();
2104
2105 let exchange = Exchange::new(Message::new("content"));
2106
2107 let result = producer.oneshot(exchange).await;
2108 assert!(result.is_err(), "Should error when no filename is provided");
2109 }
2110
2111 #[tokio::test]
2116 async fn test_file_producer_rejects_path_traversal_parent_directory() {
2117 use tower::ServiceExt;
2118
2119 let dir = tempfile::tempdir().unwrap();
2120 let dir_path = dir.path().to_str().unwrap();
2121
2122 std::fs::create_dir(dir.path().join("subdir")).unwrap();
2124 std::fs::write(dir.path().join("secret.txt"), "secret").unwrap();
2125
2126 let component = FileComponent::new();
2127 let ctx = NoOpComponentContext;
2128 let endpoint = component
2129 .create_endpoint(&format!("file:{dir_path}/subdir"), &ctx)
2130 .unwrap();
2131 let ctx = test_producer_ctx();
2132 let producer = endpoint.create_producer(&ctx).unwrap();
2133
2134 let mut exchange = Exchange::new(Message::new("malicious"));
2135 exchange.input.set_header(
2136 "CamelFileName",
2137 serde_json::Value::String("../secret.txt".to_string()),
2138 );
2139
2140 let result = producer.oneshot(exchange).await;
2141 assert!(result.is_err(), "Should reject path traversal attempt");
2142
2143 let err = result.unwrap_err();
2144 assert!(
2145 err.to_string().contains("outside"),
2146 "Error should mention path is outside base directory"
2147 );
2148 }
2149
2150 #[tokio::test]
2151 async fn test_file_producer_rejects_absolute_path_outside_base() {
2152 use tower::ServiceExt;
2153
2154 let dir = tempfile::tempdir().unwrap();
2155 let dir_path = dir.path().to_str().unwrap();
2156
2157 let component = FileComponent::new();
2158 let ctx = NoOpComponentContext;
2159 let endpoint = component
2160 .create_endpoint(&format!("file:{dir_path}"), &ctx)
2161 .unwrap();
2162 let ctx = test_producer_ctx();
2163 let producer = endpoint.create_producer(&ctx).unwrap();
2164
2165 let mut exchange = Exchange::new(Message::new("malicious"));
2166 exchange.input.set_header(
2167 "CamelFileName",
2168 serde_json::Value::String("/etc/passwd".to_string()),
2169 );
2170
2171 let result = producer.oneshot(exchange).await;
2172 assert!(result.is_err(), "Should reject absolute path outside base");
2173 }
2174
2175 #[tokio::test]
2176 async fn test_file_producer_does_not_create_dirs_before_path_validation() {
2177 use tower::ServiceExt;
2178
2179 let dir = tempfile::tempdir().unwrap();
2180 let dir_path = dir.path().to_str().unwrap();
2181
2182 let component = FileComponent::new();
2183 let ctx = NoOpComponentContext;
2184 let endpoint = component
2185 .create_endpoint(&format!("file:{dir_path}"), &ctx)
2186 .unwrap();
2187 let ctx = test_producer_ctx();
2188 let producer = endpoint.create_producer(&ctx).unwrap();
2189
2190 let outside_parent = dir.path().parent().unwrap().join("escaped-create-dir");
2191 if outside_parent.exists() {
2192 std::fs::remove_dir_all(&outside_parent).unwrap();
2193 }
2194
2195 let mut exchange = Exchange::new(Message::new("malicious"));
2196 exchange.input.set_header(
2197 "CamelFileName",
2198 serde_json::Value::String("../escaped-create-dir/file.txt".to_string()),
2199 );
2200
2201 let result = producer.oneshot(exchange).await;
2202 assert!(result.is_err(), "Should reject traversal path");
2203 assert!(
2204 !outside_parent.exists(),
2205 "Must not create outside directories before path validation"
2206 );
2207 }
2208
2209 #[cfg(unix)]
2210 #[tokio::test]
2211 async fn test_list_files_skips_symlink_cycle() {
2212 use std::os::unix::fs as unix_fs;
2213
2214 let dir = tempfile::tempdir().unwrap();
2215 let nested = dir.path().join("nested");
2216 std::fs::create_dir_all(&nested).unwrap();
2217 std::fs::write(nested.join("a.txt"), "x").unwrap();
2218 unix_fs::symlink(dir.path(), nested.join("loop")).unwrap();
2219
2220 let files = list_files(dir.path(), true).await.unwrap();
2221 assert_eq!(files.iter().filter(|p| p.ends_with("a.txt")).count(), 1);
2222 }
2223
2224 #[tokio::test]
2229 #[ignore] async fn test_large_file_streaming_constant_memory() {
2231 use std::io::Write;
2232 use tempfile::NamedTempFile;
2233
2234 let mut temp_file = NamedTempFile::new().unwrap();
2236 let file_size = 150 * 1024 * 1024; let chunk = vec![b'X'; 1024 * 1024]; for _ in 0..150 {
2240 temp_file.write_all(&chunk).unwrap();
2241 }
2242 temp_file.flush().unwrap();
2243
2244 let dir = temp_file.path().parent().unwrap();
2245 let dir_path = dir.to_str().unwrap();
2246 let file_name = temp_file
2247 .path()
2248 .file_name()
2249 .unwrap()
2250 .to_str()
2251 .unwrap()
2252 .to_string();
2253
2254 let component = FileComponent::new();
2256 let component_ctx = NoOpComponentContext;
2257 let endpoint = component
2258 .create_endpoint(
2259 &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&fileName={file_name}"),
2260 &component_ctx,
2261 )
2262 .unwrap();
2263 let mut consumer = endpoint.create_consumer().unwrap();
2264
2265 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
2266 let token = CancellationToken::new();
2267 let ctx = ConsumerContext::new(tx, token.clone());
2268
2269 tokio::spawn(async move {
2270 let _ = consumer.start(ctx).await;
2271 });
2272
2273 let exchange = tokio::time::timeout(Duration::from_secs(5), async {
2274 rx.recv().await.unwrap().exchange
2275 })
2276 .await
2277 .expect("Should receive exchange");
2278 token.cancel();
2279
2280 assert!(matches!(exchange.input.body, Body::Stream(_)));
2282
2283 if let Body::Stream(ref stream_body) = exchange.input.body {
2285 assert!(stream_body.metadata.size_hint.is_some());
2286 let size = stream_body.metadata.size_hint.unwrap();
2287 assert_eq!(size, file_size as u64);
2288 }
2289
2290 if let Body::Stream(stream_body) = exchange.input.body {
2292 let body = Body::Stream(stream_body);
2293 let result = body.into_bytes(100 * 1024 * 1024).await;
2294 assert!(result.is_err());
2295 }
2296
2297 let component2 = FileComponent::new();
2300 let endpoint2 = component2
2301 .create_endpoint(
2302 &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&fileName={file_name}"),
2303 &component_ctx,
2304 )
2305 .unwrap();
2306 let mut consumer2 = endpoint2.create_consumer().unwrap();
2307
2308 let (tx2, mut rx2) = tokio::sync::mpsc::channel(16);
2309 let token2 = CancellationToken::new();
2310 let ctx2 = ConsumerContext::new(tx2, token2.clone());
2311
2312 tokio::spawn(async move {
2313 let _ = consumer2.start(ctx2).await;
2314 });
2315
2316 let exchange2 = tokio::time::timeout(Duration::from_secs(5), async {
2317 rx2.recv().await.unwrap().exchange
2318 })
2319 .await
2320 .expect("Should receive exchange");
2321 token2.cancel();
2322
2323 if let Body::Stream(stream_body) = exchange2.input.body {
2324 let mut stream_lock = stream_body.stream.lock().await;
2325 let mut stream = stream_lock.take().unwrap();
2326
2327 if let Some(chunk_result) = stream.next().await {
2329 let chunk = chunk_result.unwrap();
2330 assert!(!chunk.is_empty());
2331 assert!(chunk.len() < file_size);
2332 }
2334 }
2335 }
2336
2337 #[tokio::test]
2342 async fn test_producer_writes_stream_body() {
2343 let dir = tempfile::tempdir().unwrap();
2344 let dir_path = dir.path().to_str().unwrap();
2345 let uri = format!("file:{dir_path}?fileName=out.txt");
2346
2347 let component = FileComponent::new();
2348 let ctx = NoOpComponentContext;
2349 let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2350 let producer = endpoint.create_producer(&test_producer_ctx()).unwrap();
2351
2352 let chunks: Vec<Result<Bytes, CamelError>> = vec![
2353 Ok(Bytes::from("hello ")),
2354 Ok(Bytes::from("streaming ")),
2355 Ok(Bytes::from("world")),
2356 ];
2357 let stream = futures::stream::iter(chunks);
2358 let body = Body::Stream(StreamBody {
2359 stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2360 metadata: StreamMetadata {
2361 size_hint: None,
2362 content_type: None,
2363 origin: None,
2364 },
2365 });
2366
2367 let exchange = Exchange::new(Message::new(body));
2368 tower::ServiceExt::oneshot(producer, exchange)
2369 .await
2370 .unwrap();
2371
2372 let content = tokio::fs::read_to_string(format!("{dir_path}/out.txt"))
2373 .await
2374 .unwrap();
2375 assert_eq!(content, "hello streaming world");
2376 }
2377
2378 #[tokio::test]
2379 async fn test_producer_stream_atomic_no_partial_on_error() {
2380 let dir = tempfile::tempdir().unwrap();
2382 let dir_path = dir.path().to_str().unwrap();
2383 let uri = format!("file:{dir_path}?fileName=out.txt");
2384
2385 let component = FileComponent::new();
2386 let ctx = NoOpComponentContext;
2387 let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2388 let producer = endpoint.create_producer(&test_producer_ctx()).unwrap();
2389
2390 let chunks: Vec<Result<Bytes, CamelError>> = vec![
2391 Ok(Bytes::from("partial")),
2392 Err(CamelError::ProcessorError(
2393 "simulated stream error".to_string(),
2394 )),
2395 ];
2396 let stream = futures::stream::iter(chunks);
2397 let body = Body::Stream(StreamBody {
2398 stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2399 metadata: StreamMetadata {
2400 size_hint: None,
2401 content_type: None,
2402 origin: None,
2403 },
2404 });
2405
2406 let exchange = Exchange::new(Message::new(body));
2407 let result = tower::ServiceExt::oneshot(producer, exchange).await;
2408 assert!(
2409 result.is_err(),
2410 "expected error when stream fails mid-write"
2411 );
2412
2413 assert!(
2415 !std::path::Path::new(&format!("{dir_path}/out.txt")).exists(),
2416 "partial file must not exist after failed write"
2417 );
2418
2419 assert!(
2421 !std::path::Path::new(&format!("{dir_path}/.tmp.out.txt")).exists(),
2422 "temp file must be cleaned up after failed write"
2423 );
2424 }
2425
2426 #[tokio::test]
2427 async fn test_producer_stream_append() {
2428 let dir = tempfile::tempdir().unwrap();
2429 let dir_path = dir.path().to_str().unwrap();
2430 let target = format!("{dir_path}/out.txt");
2431
2432 tokio::fs::write(&target, b"line1\n").await.unwrap();
2434
2435 let uri = format!("file:{dir_path}?fileName=out.txt&fileExist=Append");
2436 let component = FileComponent::new();
2437 let ctx = NoOpComponentContext;
2438 let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2439 let producer = endpoint.create_producer(&test_producer_ctx()).unwrap();
2440
2441 let chunks: Vec<Result<Bytes, CamelError>> = vec![Ok(Bytes::from("line2\n"))];
2442 let stream = futures::stream::iter(chunks);
2443 let body = Body::Stream(StreamBody {
2444 stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2445 metadata: StreamMetadata {
2446 size_hint: None,
2447 content_type: None,
2448 origin: None,
2449 },
2450 });
2451
2452 let exchange = Exchange::new(Message::new(body));
2453 tower::ServiceExt::oneshot(producer, exchange)
2454 .await
2455 .unwrap();
2456
2457 let content = tokio::fs::read_to_string(&target).await.unwrap();
2458 assert_eq!(content, "line1\nline2\n");
2459 }
2460
2461 #[tokio::test]
2462 async fn test_producer_stream_append_partial_on_error() {
2463 let dir = tempfile::tempdir().unwrap();
2466 let dir_path = dir.path().to_str().unwrap();
2467 let target = format!("{dir_path}/out.txt");
2468
2469 tokio::fs::write(&target, b"initial\n").await.unwrap();
2471
2472 let uri = format!("file:{dir_path}?fileName=out.txt&fileExist=Append");
2473 let component = FileComponent::new();
2474 let ctx = NoOpComponentContext;
2475 let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2476 let producer = endpoint.create_producer(&test_producer_ctx()).unwrap();
2477
2478 let chunks: Vec<Result<Bytes, CamelError>> = vec![
2480 Ok(Bytes::from("partial-")), Err(CamelError::ProcessorError("stream error".to_string())), Ok(Bytes::from("never-written")), ];
2484 let stream = futures::stream::iter(chunks);
2485 let body = Body::Stream(StreamBody {
2486 stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2487 metadata: StreamMetadata {
2488 size_hint: None,
2489 content_type: None,
2490 origin: None,
2491 },
2492 });
2493
2494 let exchange = Exchange::new(Message::new(body));
2495 let result = tower::ServiceExt::oneshot(producer, exchange).await;
2496
2497 assert!(
2499 result.is_err(),
2500 "expected error when stream fails during append"
2501 );
2502
2503 let content = tokio::fs::read_to_string(&target).await.unwrap();
2505 assert_eq!(
2506 content, "initial\npartial-",
2507 "append leaves partial data on stream error (non-atomic by nature)"
2508 );
2509 }
2510
2511 #[tokio::test]
2512 async fn test_producer_stream_already_consumed_errors() {
2513 let dir = tempfile::tempdir().unwrap();
2514 let dir_path = dir.path().to_str().unwrap();
2515 let uri = format!("file:{dir_path}?fileName=out.txt");
2516
2517 let component = FileComponent::new();
2518 let ctx = NoOpComponentContext;
2519 let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2520 let producer = endpoint.create_producer(&test_producer_ctx()).unwrap();
2521
2522 type MaybeStream = std::sync::Arc<
2524 tokio::sync::Mutex<
2525 Option<
2526 std::pin::Pin<
2527 Box<dyn futures::Stream<Item = Result<Bytes, CamelError>> + Send>,
2528 >,
2529 >,
2530 >,
2531 >;
2532 let arc: MaybeStream = std::sync::Arc::new(tokio::sync::Mutex::new(None));
2533 let body = Body::Stream(StreamBody {
2534 stream: arc,
2535 metadata: StreamMetadata {
2536 size_hint: None,
2537 content_type: None,
2538 origin: None,
2539 },
2540 });
2541
2542 let exchange = Exchange::new(Message::new(body));
2543 let result = tower::ServiceExt::oneshot(producer, exchange).await;
2544 assert!(
2545 result.is_err(),
2546 "expected error for already-consumed stream"
2547 );
2548 }
2549
2550 #[test]
2555 fn test_global_config_applied_to_endpoint() {
2556 let global = FileGlobalConfig::default()
2558 .with_delay_ms(2000)
2559 .with_initial_delay_ms(5000)
2560 .with_read_timeout_ms(60_000)
2561 .with_write_timeout_ms(45_000);
2562 let component = FileComponent::with_config(global);
2563 let ctx = NoOpComponentContext;
2564 let endpoint = component.create_endpoint("file:/tmp/inbox", &ctx).unwrap();
2566 let mut config = FileConfig::from_uri("file:/tmp/inbox").unwrap();
2569 let global2 = FileGlobalConfig::default()
2570 .with_delay_ms(2000)
2571 .with_initial_delay_ms(5000)
2572 .with_read_timeout_ms(60_000)
2573 .with_write_timeout_ms(45_000);
2574 config.apply_global_defaults(&global2);
2575 assert_eq!(config.delay, Duration::from_millis(2000));
2576 assert_eq!(config.initial_delay, Duration::from_millis(5000));
2577 assert_eq!(config.read_timeout, Duration::from_millis(60_000));
2578 assert_eq!(config.write_timeout, Duration::from_millis(45_000));
2579 let _ = endpoint; }
2582
2583 #[test]
2584 fn test_uri_param_wins_over_global_config() {
2585 let mut config =
2587 FileConfig::from_uri("file:/tmp/inbox?delay=1000&initialDelay=2000").unwrap();
2588 let global = FileGlobalConfig::default()
2590 .with_delay_ms(3000)
2591 .with_initial_delay_ms(4000);
2592 config.apply_global_defaults(&global);
2593 assert_eq!(config.delay, Duration::from_millis(1000));
2595 assert_eq!(config.initial_delay, Duration::from_millis(2000));
2597 assert_eq!(config.read_timeout, Duration::from_millis(30_000));
2600 }
2601
2602 #[tokio::test]
2603 async fn test_file_producer_filename_simple_language_from_header() {
2604 use tower::ServiceExt;
2605
2606 let dir = tempfile::tempdir().unwrap();
2607 let dir_path = dir.path().to_str().unwrap();
2608
2609 let component = FileComponent::new();
2610 let ctx = NoOpComponentContext;
2611 let endpoint = component
2612 .create_endpoint(&format!("file:{dir_path}"), &ctx)
2613 .unwrap();
2614 let ctx = test_producer_ctx();
2615 let producer = endpoint.create_producer(&ctx).unwrap();
2616
2617 let mut exchange = Exchange::new(Message::new("content"));
2618 exchange
2619 .input
2620 .set_header("CamelTimerCounter", serde_json::Value::Number(42.into()));
2621 exchange.input.set_header(
2622 "CamelFileName",
2623 serde_json::Value::String("test-${header.CamelTimerCounter}.txt".to_string()),
2624 );
2625
2626 producer.oneshot(exchange).await.unwrap();
2627
2628 assert!(
2629 dir.path().join("test-42.txt").exists(),
2630 "fileName should have been evaluated from Simple Language expression"
2631 );
2632 let content = std::fs::read_to_string(dir.path().join("test-42.txt")).unwrap();
2633 assert_eq!(content, "content");
2634 }
2635
2636 #[tokio::test]
2637 async fn test_file_producer_filename_simple_language_from_uri_param() {
2638 use tower::ServiceExt;
2639
2640 let dir = tempfile::tempdir().unwrap();
2641 let dir_path = dir.path().to_str().unwrap();
2642
2643 let component = FileComponent::new();
2644 let ctx = NoOpComponentContext;
2645 let endpoint = component
2646 .create_endpoint(
2647 &format!("file:{dir_path}?fileName=msg-${{header.id}}.dat"),
2648 &ctx,
2649 )
2650 .unwrap();
2651 let ctx = test_producer_ctx();
2652 let producer = endpoint.create_producer(&ctx).unwrap();
2653
2654 let mut exchange = Exchange::new(Message::new("data"));
2655 exchange
2656 .input
2657 .set_header("id", serde_json::Value::String("abc".to_string()));
2658
2659 producer.oneshot(exchange).await.unwrap();
2660
2661 assert!(
2662 dir.path().join("msg-abc.dat").exists(),
2663 "fileName URI param should have been evaluated from Simple Language expression"
2664 );
2665 }
2666
2667 #[tokio::test]
2668 async fn test_file_producer_filename_literal_without_expression() {
2669 use tower::ServiceExt;
2670
2671 let dir = tempfile::tempdir().unwrap();
2672 let dir_path = dir.path().to_str().unwrap();
2673
2674 let component = FileComponent::new();
2675 let ctx = NoOpComponentContext;
2676 let endpoint = component
2677 .create_endpoint(&format!("file:{dir_path}?fileName=plain.txt"), &ctx)
2678 .unwrap();
2679 let ctx = test_producer_ctx();
2680 let producer = endpoint.create_producer(&ctx).unwrap();
2681
2682 let exchange = Exchange::new(Message::new("data"));
2683 producer.oneshot(exchange).await.unwrap();
2684
2685 assert!(
2686 dir.path().join("plain.txt").exists(),
2687 "literal fileName without expressions should still work"
2688 );
2689 }
2690
2691 #[test]
2696 fn test_rejects_path_traversal_in_move_to() {
2697 let result = FileConfig::from_uri("file:/tmp/inbox?move=../etc/passwd");
2698 assert!(result.is_err(), "should reject path traversal in move_to");
2699 let err = result.unwrap_err().to_string();
2700 assert!(
2701 err.contains("path traversal"),
2702 "error should mention path traversal: {err}"
2703 );
2704 }
2705
2706 #[test]
2707 fn test_rejects_absolute_move_to() {
2708 let result = FileConfig::from_uri("file:/tmp/inbox?move=/tmp/outside");
2709 assert!(result.is_err(), "should reject absolute move_to");
2710 let err = result.unwrap_err().to_string();
2711 assert!(
2712 err.contains("relative path") || err.contains("Invalid URI"),
2713 "error should mention invalid move_to path: {err}"
2714 );
2715 }
2716
2717 #[test]
2718 fn test_rejects_path_traversal_in_temp_prefix() {
2719 let result = FileConfig::from_uri("file:/tmp/inbox?tempPrefix=../tmp");
2720 assert!(
2721 result.is_err(),
2722 "should reject path traversal in temp_prefix"
2723 );
2724 let err = result.unwrap_err().to_string();
2725 assert!(
2726 err.contains("path traversal"),
2727 "error should mention path traversal: {err}"
2728 );
2729 }
2730
2731 #[test]
2732 fn test_rejects_temp_prefix_with_path_separator() {
2733 let result = FileConfig::from_uri("file:/tmp/inbox?tempPrefix=tmp/sub");
2734 assert!(result.is_err(), "should reject temp_prefix with separator");
2735 let err = result.unwrap_err().to_string();
2736 assert!(
2737 err.contains("plain filename prefix"),
2738 "error should mention plain filename prefix restriction: {err}"
2739 );
2740 }
2741
2742 #[test]
2743 fn test_rejects_absolute_temp_prefix() {
2744 let result = FileConfig::from_uri("file:/tmp/inbox?tempPrefix=/tmp/");
2745 assert!(result.is_err(), "should reject absolute temp_prefix");
2746 let err = result.unwrap_err().to_string();
2747 assert!(
2748 err.contains("plain filename prefix"),
2749 "error should mention plain filename prefix restriction: {err}"
2750 );
2751 }
2752
2753 #[test]
2754 fn test_rejects_null_byte_in_temp_prefix() {
2755 let config = FileConfig {
2756 directory: "/tmp/inbox".into(),
2757 delay: Duration::from_millis(500),
2758 delay_ms: 500,
2759 initial_delay: Duration::from_millis(1000),
2760 initial_delay_ms: 1000,
2761 noop: false,
2762 delete: false,
2763 move_to: None,
2764 file_name: Some("ok.txt".into()),
2765 include: None,
2766 exclude: None,
2767 recursive: false,
2768 file_exist: FileExistStrategy::Override,
2769 read_lock_strategy: ReadLockStrategy::None,
2770 idempotent_key: IdempotentKey::None,
2771 done_file_name: None,
2772 charset: None,
2773 temp_prefix: Some("tmp\0".into()),
2774 auto_create: true,
2775 starting_directory_must_exist: false,
2776 read_timeout: Duration::from_millis(30_000),
2777 read_timeout_ms: 30_000,
2778 write_timeout: Duration::from_millis(30_000),
2779 write_timeout_ms: 30_000,
2780 };
2781
2782 let result = config.validate();
2783 assert!(result.is_err(), "should reject null byte in temp_prefix");
2784 }
2785
2786 #[test]
2787 fn test_rejects_null_byte_in_filename() {
2788 let config = FileConfig {
2791 directory: "/tmp/inbox".into(),
2792 delay: Duration::from_millis(500),
2793 delay_ms: 500,
2794 initial_delay: Duration::from_millis(1000),
2795 initial_delay_ms: 1000,
2796 noop: false,
2797 delete: false,
2798 move_to: None,
2799 file_name: Some("foo\0bar".into()),
2800 include: None,
2801 exclude: None,
2802 recursive: false,
2803 file_exist: FileExistStrategy::Override,
2804 read_lock_strategy: ReadLockStrategy::None,
2805 idempotent_key: IdempotentKey::None,
2806 done_file_name: None,
2807 charset: None,
2808 temp_prefix: None,
2809 auto_create: true,
2810 starting_directory_must_exist: false,
2811 read_timeout: Duration::from_millis(30_000),
2812 read_timeout_ms: 30_000,
2813 write_timeout: Duration::from_millis(30_000),
2814 write_timeout_ms: 30_000,
2815 };
2816 let result = config.validate();
2817 assert!(result.is_err(), "should reject null byte in filename");
2818 let err = result.unwrap_err().to_string();
2819 assert!(
2820 err.contains("null"),
2821 "error should mention null bytes: {err}"
2822 );
2823 }
2824
2825 #[test]
2826 fn test_rejects_empty_filename() {
2827 let config = FileConfig {
2828 directory: "/tmp/inbox".into(),
2829 delay: Duration::from_millis(500),
2830 delay_ms: 500,
2831 initial_delay: Duration::from_millis(1000),
2832 initial_delay_ms: 1000,
2833 noop: false,
2834 delete: false,
2835 move_to: None,
2836 file_name: Some("".into()),
2837 include: None,
2838 exclude: None,
2839 recursive: false,
2840 file_exist: FileExistStrategy::Override,
2841 read_lock_strategy: ReadLockStrategy::None,
2842 idempotent_key: IdempotentKey::None,
2843 done_file_name: None,
2844 charset: None,
2845 temp_prefix: None,
2846 auto_create: true,
2847 starting_directory_must_exist: false,
2848 read_timeout: Duration::from_millis(30_000),
2849 read_timeout_ms: 30_000,
2850 write_timeout: Duration::from_millis(30_000),
2851 write_timeout_ms: 30_000,
2852 };
2853 let result = config.validate();
2854 assert!(result.is_err(), "should reject empty filename");
2855 let err = result.unwrap_err().to_string();
2856 assert!(
2857 err.contains("empty") || err.contains("must not"),
2858 "error should mention empty: {err}"
2859 );
2860 }
2861
2862 #[test]
2863 fn test_rejects_nonexistent_directory_when_starting_directory_must_exist() {
2864 let result =
2865 FileConfig::from_uri("file:/tmp/nonexistent_dir_12345?startingDirectoryMustExist=true");
2866 assert!(
2867 result.is_err(),
2868 "should reject non-existent directory when startingDirectoryMustExist=true"
2869 );
2870 let err = result.unwrap_err().to_string();
2871 assert!(
2872 err.contains("does not exist"),
2873 "error should mention directory does not exist: {err}"
2874 );
2875 }
2876
2877 #[test]
2878 fn test_accepts_existing_directory_when_starting_directory_must_exist() {
2879 let dir = tempfile::tempdir().unwrap();
2880 let dir_path = dir.path().to_str().unwrap();
2881 let result =
2882 FileConfig::from_uri(&format!("file:{dir_path}?startingDirectoryMustExist=true"));
2883 assert!(
2884 result.is_ok(),
2885 "should accept existing directory when startingDirectoryMustExist=true: {:?}",
2886 result.err()
2887 );
2888 }
2889
2890 #[test]
2891 fn test_valid_config_passes() {
2892 let cfg = FileConfig::from_uri("file:/tmp/inbox").unwrap();
2893 assert!(cfg.validate().is_ok());
2894 }
2895
2896 #[test]
2897 fn test_file_exist_strategy_rejects_unknown() {
2898 let result = FileExistStrategy::from_str("BogusValue");
2900 assert!(
2901 result.is_err(),
2902 "unknown FileExistStrategy should return Err"
2903 );
2904 }
2905
2906 #[test]
2907 fn test_path_contains_traversal_detects_parent_dir() {
2908 assert!(path_contains_traversal("../etc/passwd"));
2909 assert!(path_contains_traversal("foo/../bar"));
2910 assert!(path_contains_traversal(".."));
2911 assert!(path_contains_traversal("a/b/../../../c"));
2912 }
2913
2914 #[test]
2915 fn test_path_contains_traversal_accepts_safe_paths() {
2916 assert!(!path_contains_traversal("safe/path"));
2917 assert!(!path_contains_traversal("/absolute/path"));
2918 assert!(!path_contains_traversal("filename.txt"));
2919 assert!(!path_contains_traversal(".hidden"));
2920 assert!(!path_contains_traversal(""));
2921 }
2922
2923 #[tokio::test]
2928 async fn test_stream_detects_file_modified_during_read() {
2929 use futures::StreamExt;
2930
2931 let dir = tempfile::tempdir().unwrap();
2932 let file_path = dir.path().join("mutable.txt");
2933 std::fs::write(&file_path, b"initial content here").unwrap();
2934
2935 let initial_meta = std::fs::metadata(&file_path).unwrap();
2937 let initial_size = initial_meta.len();
2938 let initial_mtime = initial_meta.modified().ok();
2939
2940 let file = tokio::fs::File::open(&file_path).await.unwrap();
2942 let raw_stream = ReaderStream::new(file).map(|res| res.map_err(CamelError::from));
2943
2944 let mut stream = ModificationDetectingStream::new(
2945 raw_stream,
2946 file_path.clone(),
2947 initial_size,
2948 initial_mtime,
2949 );
2950
2951 let chunk = stream.next().await;
2953 assert!(chunk.is_some(), "should produce at least one chunk");
2954 assert!(chunk.as_ref().unwrap().is_ok(), "first chunk should be Ok");
2955
2956 std::fs::write(&file_path, b"modified content - different size!!").unwrap();
2958
2959 let mut got_error = false;
2961 while let Some(result) = stream.next().await {
2962 if result.is_err() {
2963 let err = result.unwrap_err();
2964 let msg = err.to_string();
2965 assert!(
2966 msg.contains("file modified during read"),
2967 "expected modification error, got: {msg}"
2968 );
2969 got_error = true;
2970 break;
2971 }
2972 }
2973
2974 assert!(
2975 got_error,
2976 "stream should detect file was modified during read"
2977 );
2978 }
2979
2980 #[tokio::test]
2981 async fn test_stream_succeeds_when_file_not_modified() {
2982 use futures::StreamExt;
2983
2984 let dir = tempfile::tempdir().unwrap();
2985 let file_path = dir.path().join("stable.txt");
2986 std::fs::write(&file_path, b"stable content").unwrap();
2987
2988 let initial_meta = std::fs::metadata(&file_path).unwrap();
2989 let initial_size = initial_meta.len();
2990 let initial_mtime = initial_meta.modified().ok();
2991
2992 let file = tokio::fs::File::open(&file_path).await.unwrap();
2993 let raw_stream = ReaderStream::new(file).map(|res| res.map_err(CamelError::from));
2994
2995 let mut stream = ModificationDetectingStream::new(
2996 raw_stream,
2997 file_path.clone(),
2998 initial_size,
2999 initial_mtime,
3000 );
3001
3002 let mut all_ok = true;
3004 while let Some(result) = stream.next().await {
3005 if result.is_err() {
3006 all_ok = false;
3007 break;
3008 }
3009 }
3010
3011 assert!(
3012 all_ok,
3013 "stream should complete without errors when file is not modified"
3014 );
3015 }
3016}