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