1pub mod bundle;
8pub mod health;
9mod poll_logic;
10mod polling_consumer;
11
12use poll_logic::poll_directory;
13
14pub use bundle::FileBundle;
15pub use health::FileHealthCheck;
16
17use std::collections::HashSet;
18use std::future::Future;
19use std::path::PathBuf;
20use std::pin::Pin;
21use std::str::FromStr;
22use std::sync::Arc;
23use std::task::{Context, Poll};
24use std::time::Duration;
25
26use async_trait::async_trait;
27use dashmap::DashMap;
28use regex::Regex;
29use tokio::fs;
30use tokio::fs::OpenOptions;
31use tokio::io;
32use tokio::io::AsyncWriteExt;
33use tokio::time;
34use tower::Service;
35use tracing::{debug, warn};
36
37use camel_component_api::{Body, BoxProcessor, CamelError, Exchange};
38use camel_component_api::{
39 Component, Consumer, ConsumerContext, Endpoint, PollingConsumer, ProducerContext,
40};
41use camel_component_api::{UriConfig, parse_uri};
42use camel_language_api::Language;
43use camel_language_simple::SimpleLanguage;
44
45struct TempFileGuard {
54 path: PathBuf,
55 disarm: bool,
56}
57
58impl TempFileGuard {
59 fn new(path: PathBuf) -> Self {
60 Self {
61 path,
62 disarm: false,
63 }
64 }
65
66 fn disarm(&mut self) {
68 self.disarm = true;
69 }
70}
71
72impl Drop for TempFileGuard {
73 fn drop(&mut self) {
74 if !self.disarm {
75 let _ = std::fs::remove_file(&self.path);
77 }
78 }
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
87pub enum FileExistStrategy {
88 #[default]
90 Override,
91 Append,
93 Fail,
95 Ignore,
97 TryRename,
98}
99
100impl FromStr for FileExistStrategy {
101 type Err = String;
102
103 fn from_str(s: &str) -> Result<Self, Self::Err> {
104 match s {
105 "Override" | "override" => Ok(FileExistStrategy::Override),
106 "Append" | "append" => Ok(FileExistStrategy::Append),
107 "Fail" | "fail" => Ok(FileExistStrategy::Fail),
108 "Ignore" | "ignore" => Ok(FileExistStrategy::Ignore),
109 "TryRename" | "tryRename" => Ok(FileExistStrategy::TryRename),
110 other => Err(format!("unknown FileExistStrategy: {other}")),
111 }
112 }
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
116pub enum ReadLockStrategy {
117 #[default]
118 None,
119 InProcess,
120 Rename,
121}
122
123impl FromStr for ReadLockStrategy {
124 type Err = String;
125
126 fn from_str(s: &str) -> Result<Self, Self::Err> {
127 match s {
128 "None" | "none" => Ok(Self::None),
129 "InProcess" | "inProcess" | "inprocess" => Ok(Self::InProcess),
130 "Rename" | "rename" => Ok(Self::Rename),
131 other => Err(format!("unknown ReadLockStrategy: {other}")),
132 }
133 }
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
137pub enum IdempotentKey {
138 #[default]
139 None,
140 FileName,
141 FilePath,
142 FileSize,
143 Digest,
145}
146
147impl FromStr for IdempotentKey {
148 type Err = String;
149
150 fn from_str(s: &str) -> Result<Self, Self::Err> {
151 match s {
152 "None" | "none" => Ok(Self::None),
153 "FileName" | "fileName" | "filename" => Ok(Self::FileName),
154 "FilePath" | "filePath" | "filepath" => Ok(Self::FilePath),
155 "FileSize" | "fileSize" | "filesize" => Ok(Self::FileSize),
156 "Digest" | "digest" => Ok(Self::Digest),
157 other => Err(format!("unknown IdempotentKey: {other}")),
158 }
159 }
160}
161
162#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
170#[serde(default)]
171pub struct FileGlobalConfig {
172 pub delay_ms: u64,
173 pub initial_delay_ms: u64,
174 pub read_timeout_ms: u64,
175 pub write_timeout_ms: u64,
176}
177
178impl Default for FileGlobalConfig {
179 fn default() -> Self {
180 Self {
181 delay_ms: 500,
182 initial_delay_ms: 1_000,
183 read_timeout_ms: 30_000,
184 write_timeout_ms: 30_000,
185 }
186 }
187}
188
189impl FileGlobalConfig {
190 pub fn new() -> Self {
191 Self::default()
192 }
193 pub fn with_delay_ms(mut self, v: u64) -> Self {
194 self.delay_ms = v;
195 self
196 }
197 pub fn with_initial_delay_ms(mut self, v: u64) -> Self {
198 self.initial_delay_ms = v;
199 self
200 }
201 pub fn with_read_timeout_ms(mut self, v: u64) -> Self {
202 self.read_timeout_ms = v;
203 self
204 }
205 pub fn with_write_timeout_ms(mut self, v: u64) -> Self {
206 self.write_timeout_ms = v;
207 self
208 }
209}
210
211#[derive(Debug, Clone, Default)]
216pub(crate) struct CompiledFilters {
217 pub include_re: Option<Regex>,
218 pub exclude_re: Option<Regex>,
219 pub ant_include_patterns: Option<Vec<glob::Pattern>>,
220 pub ant_exclude_patterns: Option<Vec<glob::Pattern>>,
221 pub include_exts: Option<Vec<String>>,
222 pub exclude_exts: Option<Vec<String>>,
223}
224
225impl CompiledFilters {
226 pub fn compile(config: &FileConfig) -> Result<Self, CamelError> {
227 let include_re = config
228 .include
229 .as_deref()
230 .map(|p| {
231 Regex::new(p).map_err(|e| CamelError::Config(format!("invalid include regex: {e}")))
232 })
233 .transpose()?;
234 let exclude_re = config
235 .exclude
236 .as_deref()
237 .map(|p| {
238 Regex::new(p).map_err(|e| CamelError::Config(format!("invalid exclude regex: {e}")))
239 })
240 .transpose()?;
241 let ant_include_patterns = config
242 .ant_include
243 .as_deref()
244 .map(|list| {
245 list.split(',')
246 .map(|s| {
247 s.trim().parse::<glob::Pattern>().map_err(|e| {
248 CamelError::Config(format!("invalid antInclude pattern '{s}': {e}"))
249 })
250 })
251 .collect::<Result<Vec<_>, _>>()
252 })
253 .transpose()?;
254 let ant_exclude_patterns = config
255 .ant_exclude
256 .as_deref()
257 .map(|list| {
258 list.split(',')
259 .map(|s| {
260 s.trim().parse::<glob::Pattern>().map_err(|e| {
261 CamelError::Config(format!("invalid antExclude pattern '{s}': {e}"))
262 })
263 })
264 .collect::<Result<Vec<_>, _>>()
265 })
266 .transpose()?;
267 let include_exts = config
268 .include_ext
269 .as_deref()
270 .map(|list| list.split(',').map(|s| s.trim().to_lowercase()).collect());
271 let exclude_exts = config
272 .exclude_ext
273 .as_deref()
274 .map(|list| list.split(',').map(|s| s.trim().to_lowercase()).collect());
275 Ok(Self {
276 include_re,
277 exclude_re,
278 ant_include_patterns,
279 ant_exclude_patterns,
280 include_exts,
281 exclude_exts,
282 })
283 }
284}
285
286#[derive(Debug, Clone, Copy, PartialEq, Eq)]
291pub(crate) enum SortField {
292 Name,
293 Length,
294 Modified,
295}
296
297#[derive(Debug, Clone)]
298pub(crate) struct SortGroup {
299 pub field: SortField,
300 pub reverse: bool,
301 pub ignore_case: bool,
302}
303
304#[derive(Debug, Clone)]
305pub(crate) struct SortSpec {
306 pub groups: Vec<SortGroup>,
307}
308
309impl FromStr for SortSpec {
310 type Err = String;
311
312 fn from_str(s: &str) -> Result<Self, Self::Err> {
313 let mut groups = Vec::new();
314 for raw_group in s.split(';') {
315 let trimmed = raw_group.trim();
316 if trimmed.is_empty() {
317 continue;
318 }
319 let mut reverse = false;
320 let mut ignore_case = false;
321 let mut remaining = trimmed;
322 let mut saw_ignore_case = false;
323
324 loop {
325 if let Some(rest) = remaining.strip_prefix("reverse:") {
326 if saw_ignore_case {
327 return Err("sortBy: reverse must precede ignoreCase".into());
328 }
329 reverse = true;
330 remaining = rest;
331 continue;
332 }
333 if let Some(rest) = remaining.strip_prefix("ignoreCase:") {
334 ignore_case = true;
335 saw_ignore_case = true;
336 remaining = rest;
337 continue;
338 }
339 break;
340 }
341
342 let field = match remaining {
343 "file:name" => SortField::Name,
344 "file:length" => SortField::Length,
345 "file:modified" => SortField::Modified,
346 other => return Err(format!("unsupported sortBy field: '{other}'")),
347 };
348
349 groups.push(SortGroup {
350 field,
351 reverse,
352 ignore_case,
353 });
354 }
355 if groups.is_empty() {
356 return Err("sortBy must have at least one group".into());
357 }
358 Ok(SortSpec { groups })
359 }
360}
361
362#[derive(Debug, Clone)]
390pub struct FileConfig {
391 pub directory: String,
393
394 #[allow(dead_code)]
396 delay_ms: u64,
397
398 pub delay: Duration,
400
401 #[allow(dead_code)]
403 initial_delay_ms: u64,
404
405 pub initial_delay: Duration,
407
408 pub noop: bool,
410
411 pub delete: bool,
413
414 move_to: Option<String>,
417
418 pub file_name: Option<String>,
420
421 pub include: Option<String>,
423
424 pub exclude: Option<String>,
426
427 pub ant_include: Option<String>,
429
430 pub ant_exclude: Option<String>,
432
433 pub include_ext: Option<String>,
435
436 pub exclude_ext: Option<String>,
438
439 pub recursive: bool,
441
442 pub file_exist: FileExistStrategy,
444
445 pub read_lock_strategy: ReadLockStrategy,
447
448 pub idempotent_key: IdempotentKey,
450
451 pub done_file_name: Option<String>,
453
454 pub charset: Option<String>,
456
457 pub temp_prefix: Option<String>,
459
460 pub auto_create: bool,
462
463 pub starting_directory_must_exist: bool,
465
466 #[allow(dead_code)]
468 read_timeout_ms: u64,
469
470 pub read_timeout: Duration,
472
473 #[allow(dead_code)]
475 write_timeout_ms: u64,
476
477 pub write_timeout: Duration,
479
480 pub max_depth: usize,
481 pub min_depth: usize,
482 pub max_messages_per_poll: i64,
483 pub eager_max_messages_per_poll: bool,
484 pub shuffle: bool,
485 pub(crate) sort_spec: Option<SortSpec>,
486}
487
488impl UriConfig for FileConfig {
489 fn scheme() -> &'static str {
490 "file"
491 }
492
493 fn from_uri(uri: &str) -> Result<Self, CamelError> {
494 let parts = parse_uri(uri)?;
495 Self::from_components(parts)
496 }
497
498 fn from_components(parts: camel_component_api::UriComponents) -> Result<Self, CamelError> {
499 if parts.scheme != Self::scheme() {
500 return Err(CamelError::InvalidUri(format!(
501 "unsupported scheme '{}', expected '{}'",
502 parts.scheme,
503 Self::scheme()
504 )));
505 }
506
507 fn parse_bool_param(
508 params: &std::collections::HashMap<String, String>,
509 key: &str,
510 default: bool,
511 ) -> Result<bool, CamelError> {
512 match params.get(key) {
513 Some(v) => match v.to_lowercase().as_str() {
514 "true" | "1" | "yes" => Ok(true),
515 "false" | "0" | "no" => Ok(false),
516 _ => Err(CamelError::InvalidUri(format!(
517 "invalid value for {key}: invalid boolean value: '{v}'"
518 ))),
519 },
520 None => Ok(default),
521 }
522 }
523
524 fn parse_u64_param(
525 params: &std::collections::HashMap<String, String>,
526 key: &str,
527 default: u64,
528 ) -> Result<u64, CamelError> {
529 match params.get(key) {
530 Some(v) => v
531 .parse::<u64>()
532 .map_err(|e| CamelError::InvalidUri(format!("invalid value for {key}: {e}"))),
533 None => Ok(default),
534 }
535 }
536
537 fn parse_enum_param<T: FromStr>(
538 params: &std::collections::HashMap<String, String>,
539 key: &str,
540 default: &str,
541 ) -> Result<T, CamelError>
542 where
543 T::Err: std::fmt::Display,
544 {
545 let raw = params.get(key).map(String::as_str).unwrap_or(default);
546 raw.parse::<T>().map_err(|e| {
547 CamelError::InvalidUri(format!("invalid value for parameter '{key}': {e}"))
548 })
549 }
550
551 let params = &parts.params;
552 let delay_ms = parse_u64_param(params, "delay", 500)?;
553 let initial_delay_ms = parse_u64_param(params, "initialDelay", 1000)?;
554 let read_timeout_ms = parse_u64_param(params, "readTimeout", 30_000)?;
555 let write_timeout_ms = parse_u64_param(params, "writeTimeout", 30_000)?;
556
557 let cfg = Self {
558 directory: parts.path,
559 delay_ms,
560 delay: Duration::from_millis(delay_ms),
561 initial_delay_ms,
562 initial_delay: Duration::from_millis(initial_delay_ms),
563 noop: parse_bool_param(params, "noop", false)?,
564 delete: parse_bool_param(params, "delete", false)?,
565 move_to: params.get("move").cloned(),
566 file_name: params.get("fileName").cloned(),
567 include: params.get("include").cloned(),
568 exclude: params.get("exclude").cloned(),
569 recursive: parse_bool_param(params, "recursive", false)?,
570 file_exist: parse_enum_param(params, "fileExist", "Override")?,
571 read_lock_strategy: parse_enum_param(params, "readLock", "None")?,
572 idempotent_key: parse_enum_param(params, "idempotentKey", "None")?,
573 done_file_name: params.get("doneFileName").cloned(),
574 charset: params.get("charset").cloned(),
575 temp_prefix: params.get("tempPrefix").cloned(),
576 auto_create: parse_bool_param(params, "autoCreate", true)?,
577 starting_directory_must_exist: parse_bool_param(
578 params,
579 "startingDirectoryMustExist",
580 false,
581 )?,
582 read_timeout_ms,
583 read_timeout: Duration::from_millis(read_timeout_ms),
584 write_timeout_ms,
585 write_timeout: Duration::from_millis(write_timeout_ms),
586 max_depth: parse_u64_param(params, "maxDepth", u64::MAX)? as usize,
587 min_depth: parse_u64_param(params, "minDepth", 0)? as usize,
588 max_messages_per_poll: params
589 .get("maxMessagesPerPoll")
590 .map(|v| {
591 v.parse::<i64>().map_err(|e| {
592 CamelError::InvalidUri(format!("invalid value for maxMessagesPerPoll: {e}"))
593 })
594 })
595 .transpose()?
596 .unwrap_or(0),
597 eager_max_messages_per_poll: parse_bool_param(params, "eagerMaxMessagesPerPoll", true)?,
598 ant_include: params.get("antInclude").cloned(),
599 ant_exclude: params.get("antExclude").cloned(),
600 include_ext: params.get("includeExt").cloned(),
601 exclude_ext: params.get("excludeExt").cloned(),
602 shuffle: parse_bool_param(params, "shuffle", false)?,
603 sort_spec: params
604 .get("sortBy")
605 .map(|v| v.parse::<SortSpec>())
606 .transpose()
607 .map_err(|e| CamelError::InvalidUri(format!("invalid sortBy: {e}")))?,
608 };
609
610 cfg.validate()
611 }
612
613 fn validate(self) -> Result<Self, CamelError> {
614 if let Some(ref move_to) = self.move_to
616 && path_contains_traversal(move_to)
617 {
618 return Err(CamelError::Config(format!(
619 "move_to contains path traversal component: {move_to}"
620 )));
621 }
622
623 if let Some(ref move_to) = self.move_to
624 && std::path::Path::new(move_to).is_absolute()
625 {
626 return Err(CamelError::InvalidUri(format!(
627 "move_to must be relative path within base directory: {move_to}"
628 )));
629 }
630
631 if let Some(ref temp_prefix) = self.temp_prefix
633 && path_contains_traversal(temp_prefix)
634 {
635 return Err(CamelError::Config(format!(
636 "temp_prefix contains path traversal component: {temp_prefix}"
637 )));
638 }
639
640 if let Some(ref temp_prefix) = self.temp_prefix
641 && !is_valid_temp_prefix(temp_prefix)
642 {
643 return Err(CamelError::Config(
644 "temp_prefix must be plain filename prefix (no path separators, absolute paths, or null bytes)".into(),
645 ));
646 }
647
648 if self.file_exist == FileExistStrategy::TryRename && self.temp_prefix.is_none() {
649 return Err(CamelError::Config(
650 "fileExist=TryRename requires tempPrefix to be set".into(),
651 ));
652 }
653
654 if let Some(ref file_name) = self.file_name {
656 if file_name.is_empty() {
657 return Err(CamelError::Config("file_name must not be empty".into()));
658 }
659 if file_name.contains('\0') {
661 return Err(CamelError::Config(
662 "file_name must not contain null bytes".into(),
663 ));
664 }
665 }
666
667 if self.min_depth > self.max_depth {
668 return Err(CamelError::Config(
669 "minDepth cannot be greater than maxDepth".into(),
670 ));
671 }
672
673 if self.starting_directory_must_exist {
675 let dir_path = std::path::Path::new(&self.directory);
676 if !dir_path.exists() {
677 return Err(CamelError::Config(format!(
678 "starting directory does not exist: {}",
679 self.directory
680 )));
681 }
682 }
683
684 let move_to = if self.noop || self.delete {
688 None
689 } else {
690 Some(self.move_to.unwrap_or_else(|| ".camel".to_string()))
691 };
692
693 Ok(Self { move_to, ..self })
694 }
695}
696
697impl FileConfig {
698 pub fn apply_global_defaults(&mut self, global: &FileGlobalConfig) {
706 if self.delay == Duration::from_millis(500) {
707 self.delay = Duration::from_millis(global.delay_ms);
708 }
709 if self.initial_delay == Duration::from_millis(1_000) {
710 self.initial_delay = Duration::from_millis(global.initial_delay_ms);
711 }
712 if self.read_timeout == Duration::from_millis(30_000) {
713 self.read_timeout = Duration::from_millis(global.read_timeout_ms);
714 }
715 if self.write_timeout == Duration::from_millis(30_000) {
716 self.write_timeout = Duration::from_millis(global.write_timeout_ms);
717 }
718 }
719}
720
721pub struct FileComponent {
726 config: Option<FileGlobalConfig>,
727}
728
729impl FileComponent {
730 pub fn new() -> Self {
731 Self { config: None }
732 }
733
734 pub fn with_config(config: FileGlobalConfig) -> Self {
735 Self {
736 config: Some(config),
737 }
738 }
739
740 pub fn with_optional_config(config: Option<FileGlobalConfig>) -> Self {
741 Self { config }
742 }
743}
744
745impl Default for FileComponent {
746 fn default() -> Self {
747 Self::new()
748 }
749}
750
751impl Component for FileComponent {
752 fn scheme(&self) -> &str {
753 "file"
754 }
755
756 fn create_endpoint(
757 &self,
758 uri: &str,
759 ctx: &dyn camel_component_api::ComponentContext,
760 ) -> Result<Box<dyn Endpoint>, CamelError> {
761 let mut config = FileConfig::from_uri(uri)?;
762 if let Some(ref global_config) = self.config {
763 config.apply_global_defaults(global_config);
764 }
765 let filters = CompiledFilters::compile(&config)?;
766 let dir_path = std::path::PathBuf::from(&config.directory);
767 let health_check = FileHealthCheck::new(dir_path.clone());
768 ctx.register_current_route_health_check(std::sync::Arc::new(health_check));
769 Ok(Box::new(FileEndpoint {
770 uri: uri.to_string(),
771 config,
772 filters,
773 in_process_locks: std::sync::Arc::new(DashMap::new()),
774 idempotent_repo: std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new())),
775 }))
776 }
777}
778
779struct FileEndpoint {
784 uri: String,
785 config: FileConfig,
786 filters: CompiledFilters,
787 in_process_locks: std::sync::Arc<DashMap<PathBuf, ()>>,
788 idempotent_repo: std::sync::Arc<tokio::sync::Mutex<HashSet<String>>>,
789}
790
791impl Endpoint for FileEndpoint {
792 fn uri(&self) -> &str {
793 &self.uri
794 }
795
796 fn create_consumer(
797 &self,
798 rt: Arc<dyn camel_component_api::RuntimeObservability>,
799 ) -> Result<Box<dyn Consumer>, CamelError> {
800 Ok(Box::new(FileConsumer::new(
801 self.config.clone(),
802 self.filters.clone(),
803 self.in_process_locks.clone(),
804 self.idempotent_repo.clone(),
805 rt,
806 )))
807 }
808
809 fn create_producer(
810 &self,
811 _rt: Arc<dyn camel_component_api::RuntimeObservability>,
812 _ctx: &ProducerContext,
813 ) -> Result<BoxProcessor, CamelError> {
814 Ok(BoxProcessor::new(FileProducer {
815 config: self.config.clone(),
816 }))
817 }
818
819 fn polling_consumer(&self) -> Option<Box<dyn PollingConsumer>> {
820 Some(Box::new(polling_consumer::FilePollingConsumer::new(
821 self.config.clone(),
822 self.in_process_locks.clone(),
823 self.idempotent_repo.clone(),
824 self.filters.clone(),
825 )))
826 }
827}
828
829struct FileConsumer {
834 config: FileConfig,
835 filters: CompiledFilters,
836 seen: HashSet<PathBuf>,
837 in_process_locks: std::sync::Arc<DashMap<PathBuf, ()>>,
838 idempotent_repo: std::sync::Arc<tokio::sync::Mutex<HashSet<String>>>,
839 #[allow(dead_code)]
840 runtime: Arc<dyn camel_component_api::RuntimeObservability>,
841}
842
843impl FileConsumer {
844 fn new(
845 config: FileConfig,
846 filters: CompiledFilters,
847 in_process_locks: std::sync::Arc<DashMap<PathBuf, ()>>,
848 idempotent_repo: std::sync::Arc<tokio::sync::Mutex<HashSet<String>>>,
849 runtime: Arc<dyn camel_component_api::RuntimeObservability>,
850 ) -> Self {
851 Self {
852 config,
853 filters,
854 seen: HashSet::new(),
855 in_process_locks,
856 idempotent_repo,
857 runtime,
858 }
859 }
860}
861
862#[async_trait]
863impl Consumer for FileConsumer {
864 async fn start(&mut self, context: ConsumerContext) -> Result<(), CamelError> {
865 let config = self.config.clone();
866
867 if !config.initial_delay.is_zero() {
868 tokio::select! {
869 _ = time::sleep(config.initial_delay) => {}
870 _ = context.cancelled() => {
871 debug!(directory = config.directory, "File consumer cancelled during initial delay");
872 return Ok(());
873 }
874 }
875 }
876
877 let mut interval = time::interval(config.delay);
878
879 loop {
880 tokio::select! {
881 _ = context.cancelled() => {
882 debug!(directory = config.directory, "File consumer received cancellation, stopping");
883 break;
884 }
885 _ = interval.tick() => {
886 if let Err(e) = poll_directory(
887 &config,
888 &context,
889 &self.filters,
890 &mut self.seen,
891 &self.in_process_locks,
892 &self.idempotent_repo,
893 ).await {
894 warn!(directory = config.directory, error = %e, "Error polling directory");
895 }
896 }
897 }
898 }
899
900 Ok(())
901 }
902
903 async fn stop(&mut self) -> Result<(), CamelError> {
904 Ok(())
905 }
906}
907
908fn path_contains_traversal(path: &str) -> bool {
914 std::path::Path::new(path)
915 .components()
916 .any(|c| matches!(c, std::path::Component::ParentDir))
917}
918
919fn is_valid_temp_prefix(prefix: &str) -> bool {
920 !prefix.contains('\0')
921 && !std::path::Path::new(prefix).is_absolute()
922 && !prefix.contains(std::path::MAIN_SEPARATOR)
923 && !prefix.contains('/')
924 && !prefix.contains('\\')
925}
926
927fn validate_path_is_within_base(
928 base_dir: &std::path::Path,
929 target_path: &std::path::Path,
930) -> Result<(), CamelError> {
931 if base_dir.exists() {
935 let canonical_base = base_dir.canonicalize().map_err(|e| {
936 CamelError::ProcessorError(format!("Cannot canonicalize base directory: {}", e))
937 })?;
938
939 let canonical_target = if target_path.exists() {
940 target_path.canonicalize().map_err(|e| {
941 CamelError::ProcessorError(format!("Cannot canonicalize target path: {}", e))
942 })?
943 } else if let Some(parent) = target_path.parent() {
944 if parent.exists() {
945 let canonical_parent = parent.canonicalize().map_err(|e| {
946 CamelError::ProcessorError(format!(
947 "Cannot canonicalize parent directory: {}",
948 e
949 ))
950 })?;
951 if let Some(filename) = target_path.file_name() {
952 canonical_parent.join(filename)
953 } else {
954 return Err(CamelError::ProcessorError(
955 "Invalid target path: no filename".to_string(),
956 ));
957 }
958 } else {
959 let rel = target_path.strip_prefix(base_dir).map_err(|_| {
961 CamelError::ProcessorError(format!(
962 "Path '{}' is not under base '{}'",
963 target_path.display(),
964 base_dir.display()
965 ))
966 })?;
967 if path_contains_traversal(&rel.to_string_lossy()) {
968 return Err(CamelError::ProcessorError(format!(
969 "Path '{}' contains directory traversal",
970 target_path.display()
971 )));
972 }
973 return Ok(());
974 }
975 } else {
976 return Err(CamelError::ProcessorError(
977 "Invalid target path: no parent directory".to_string(),
978 ));
979 };
980
981 if !canonical_target.starts_with(&canonical_base) {
982 return Err(CamelError::ProcessorError(format!(
983 "Path '{}' is outside base directory '{}'",
984 canonical_target.display(),
985 canonical_base.display()
986 )));
987 }
988 } else {
989 let rel = target_path.strip_prefix(base_dir).map_err(|_| {
992 CamelError::ProcessorError(format!(
993 "Path '{}' is not under base '{}'",
994 target_path.display(),
995 base_dir.display()
996 ))
997 })?;
998 let rel_str = rel.to_string_lossy();
999 if path_contains_traversal(&rel_str) {
1000 return Err(CamelError::ProcessorError(format!(
1001 "Path '{}' contains directory traversal",
1002 target_path.display()
1003 )));
1004 }
1005 }
1006
1007 Ok(())
1008}
1009
1010#[derive(Clone)]
1015struct FileProducer {
1016 config: FileConfig,
1017}
1018
1019impl FileProducer {
1020 async fn resolve_filename(
1021 exchange: &Exchange,
1022 config: &FileConfig,
1023 ) -> Result<String, CamelError> {
1024 let raw = if let Some(name) = exchange
1025 .input
1026 .header("CamelFileName")
1027 .and_then(|v| v.as_str())
1028 {
1029 Some(name.to_string())
1030 } else {
1031 config.file_name.clone()
1032 };
1033
1034 match raw {
1035 Some(name) if name.contains("${") => {
1036 let lang = SimpleLanguage::new();
1037 let expr = lang.create_expression(&name).map_err(|e| {
1038 CamelError::ProcessorError(format!(
1039 "cannot parse fileName expression '{}': {e}",
1040 name
1041 ))
1042 })?;
1043 let val = expr.evaluate(exchange).await.map_err(|e| {
1044 CamelError::ProcessorError(format!(
1045 "cannot evaluate fileName expression '{}': {e}",
1046 name
1047 ))
1048 })?;
1049 match val {
1050 serde_json::Value::String(s) => Ok(s),
1051 other => Ok(other.to_string()),
1052 }
1053 }
1054 Some(name) => Ok(name),
1055 None => Err(CamelError::ProcessorError(
1056 "No filename specified: set CamelFileName header or fileName option".to_string(),
1057 )),
1058 }
1059 }
1060}
1061
1062impl Service<Exchange> for FileProducer {
1063 type Response = Exchange;
1064 type Error = CamelError;
1065 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
1066
1067 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
1068 Poll::Ready(Ok(()))
1069 }
1070
1071 fn call(&mut self, mut exchange: Exchange) -> Self::Future {
1072 let config = self.config.clone();
1073
1074 Box::pin(async move {
1075 let file_name = FileProducer::resolve_filename(&exchange, &config).await?;
1076 let body = exchange.input.body.clone();
1077
1078 let dir_path = std::path::Path::new(&config.directory);
1079 let target_path = dir_path.join(&file_name);
1080
1081 validate_path_is_within_base(dir_path, &target_path)?;
1083
1084 if config.auto_create
1086 && let Some(parent) = target_path.parent()
1087 {
1088 tokio::time::timeout(config.write_timeout, fs::create_dir_all(parent))
1089 .await
1090 .map_err(|_| CamelError::ProcessorError("Timeout creating directories".into()))?
1091 .map_err(CamelError::from)?;
1092 }
1093
1094 match config.file_exist {
1096 FileExistStrategy::Fail => {
1097 let mut file = tokio::time::timeout(
1098 config.write_timeout,
1099 OpenOptions::new()
1100 .write(true)
1101 .create_new(true)
1102 .open(&target_path),
1103 )
1104 .await
1105 .map_err(|_| {
1106 CamelError::ProcessorError("Timeout opening file with create_new".into())
1107 })?
1108 .map_err(CamelError::from)?;
1109
1110 write_body_with_charset(body, &config.charset, &mut file, config.write_timeout)
1111 .await?;
1112 file.flush().await.map_err(CamelError::from)?;
1113 }
1114 FileExistStrategy::Ignore if target_path.exists() => return Ok(exchange),
1115 FileExistStrategy::Append => {
1116 let mut file = tokio::time::timeout(
1118 config.write_timeout,
1119 OpenOptions::new()
1120 .append(true)
1121 .create(true)
1122 .open(&target_path),
1123 )
1124 .await
1125 .map_err(|_| {
1126 CamelError::ProcessorError("Timeout opening file for append".into())
1127 })?
1128 .map_err(CamelError::from)?;
1129
1130 write_body_with_charset(body, &config.charset, &mut file, config.write_timeout)
1131 .await?;
1132
1133 file.flush().await.map_err(CamelError::from)?;
1134 }
1135 FileExistStrategy::TryRename => {
1136 let prefix = config.temp_prefix.as_deref().ok_or_else(|| {
1137 CamelError::Config("fileExist=TryRename requires tempPrefix".into())
1138 })?;
1139 let temp_name = format!(
1140 "{prefix}{}",
1141 target_path
1142 .file_name()
1143 .unwrap_or_default()
1144 .to_string_lossy()
1145 );
1146 let temp_path = target_path.with_file_name(&temp_name);
1147
1148 let mut guard = TempFileGuard::new(temp_path.clone());
1150
1151 let mut temp_file = tokio::time::timeout(
1152 config.write_timeout,
1153 OpenOptions::new()
1154 .write(true)
1155 .create_new(true)
1156 .open(&temp_path),
1157 )
1158 .await
1159 .map_err(|_| CamelError::ProcessorError("Timeout creating temp file".into()))?
1160 .map_err(CamelError::from)?;
1161 write_body_with_charset(
1162 body,
1163 &config.charset,
1164 &mut temp_file,
1165 config.write_timeout,
1166 )
1167 .await?;
1168 temp_file.flush().await.map_err(CamelError::from)?;
1169
1170 let rename_result = tokio::time::timeout(
1171 config.write_timeout,
1172 fs::rename(&temp_path, &target_path),
1173 )
1174 .await;
1175
1176 match rename_result {
1177 Ok(Ok(_)) => {
1178 guard.disarm();
1179 }
1180 Ok(Err(e)) => {
1181 return Err(CamelError::from(e));
1182 }
1183 Err(_) => {
1184 return Err(CamelError::ProcessorError(
1185 "Timeout renaming temp file".into(),
1186 ));
1187 }
1188 }
1189 }
1190 _ => {
1191 let temp_name = if let Some(ref prefix) = config.temp_prefix {
1193 format!("{prefix}{file_name}")
1194 } else {
1195 format!(".tmp.{file_name}")
1196 };
1197 let temp_path = dir_path.join(&temp_name);
1198
1199 let mut guard = TempFileGuard::new(temp_path.clone());
1201
1202 let mut file =
1204 tokio::time::timeout(config.write_timeout, fs::File::create(&temp_path))
1205 .await
1206 .map_err(|_| {
1207 CamelError::ProcessorError("Timeout creating temp file".into())
1208 })?
1209 .map_err(CamelError::from)?;
1210
1211 let copy_result = write_body_with_charset(
1212 body,
1213 &config.charset,
1214 &mut file,
1215 config.write_timeout,
1216 )
1217 .await;
1218
1219 let _ = file.flush().await;
1221
1222 copy_result?;
1223
1224 let rename_result = tokio::time::timeout(
1226 config.write_timeout,
1227 fs::rename(&temp_path, &target_path),
1228 )
1229 .await;
1230
1231 match rename_result {
1232 Ok(Ok(_)) => {
1233 guard.disarm();
1235 }
1236 Ok(Err(e)) => {
1237 return Err(CamelError::from(e));
1239 }
1240 Err(_) => {
1241 return Err(CamelError::ProcessorError("Timeout renaming file".into()));
1243 }
1244 }
1245 }
1246 }
1247
1248 if let Some(done_pattern) = &config.done_file_name {
1249 let done_name = done_pattern.replace("${file:name}", &file_name);
1250 tokio::time::timeout(
1251 config.write_timeout,
1252 fs::write(dir_path.join(done_name), []),
1253 )
1254 .await
1255 .map_err(|_| CamelError::ProcessorError("Timeout creating done file".into()))?
1256 .map_err(CamelError::from)?;
1257 }
1258
1259 let abs_path = target_path
1261 .canonicalize()
1262 .unwrap_or_else(|_| target_path.clone())
1263 .to_string_lossy()
1264 .to_string();
1265 exchange
1266 .input
1267 .set_header("CamelFileNameProduced", serde_json::Value::String(abs_path));
1268
1269 debug!(
1270 file = %target_path.display(),
1271 correlation_id = %exchange.correlation_id(),
1272 "File written"
1273 );
1274
1275 Ok(exchange)
1276 })
1277 }
1278}
1279
1280async fn write_body_with_charset(
1281 body: Body,
1282 charset: &Option<String>,
1283 file: &mut fs::File,
1284 timeout: Duration,
1285) -> Result<(), CamelError> {
1286 match body {
1287 Body::Text(text) | Body::Xml(text) => {
1288 let bytes = encode_text_by_charset(&text, charset)?;
1289 tokio::time::timeout(timeout, file.write_all(&bytes))
1290 .await
1291 .map_err(|_| CamelError::ProcessorError("Timeout writing file".into()))?
1292 .map_err(CamelError::from)?;
1293 Ok(())
1294 }
1295 other => {
1296 let mut reader = other.into_async_read()?;
1297 tokio::time::timeout(timeout, io::copy(&mut reader, file))
1298 .await
1299 .map_err(|_| CamelError::ProcessorError("Timeout writing to file".into()))?
1300 .map_err(|e| CamelError::ProcessorError(e.to_string()))?;
1301 Ok(())
1302 }
1303 }
1304}
1305
1306fn encode_text_by_charset(text: &str, charset: &Option<String>) -> Result<Vec<u8>, CamelError> {
1307 let Some(charset) = charset.as_ref() else {
1308 return Ok(text.as_bytes().to_vec());
1309 };
1310
1311 if charset.eq_ignore_ascii_case("utf-8") {
1312 return Ok(text.as_bytes().to_vec());
1313 }
1314
1315 if charset.eq_ignore_ascii_case("iso-8859-1") {
1316 let mut out = Vec::with_capacity(text.len());
1317 for ch in text.chars() {
1318 let code = ch as u32;
1319 if code > 0xFF {
1320 return Err(CamelError::ProcessorError(format!(
1321 "character '{ch}' cannot be encoded as ISO-8859-1"
1322 )));
1323 }
1324 out.push(code as u8);
1325 }
1326 return Ok(out);
1327 }
1328
1329 Err(CamelError::Config(format!(
1330 "unsupported charset '{charset}', supported: UTF-8, ISO-8859-1"
1331 )))
1332}
1333
1334#[cfg(test)]
1335mod tests {
1336 use camel_component_api::test_support::PanicRuntimeObservability;
1337 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
1338 std::sync::Arc::new(PanicRuntimeObservability)
1339 }
1340
1341 use super::*;
1342 use crate::poll_logic::{
1343 ModificationDetectingStream, apply_sort_and_limit, list_files, poll_one_file,
1344 scan_candidates,
1345 };
1346 use bytes::Bytes;
1347 use camel_component_api::{Message, NoOpComponentContext, StreamBody, StreamMetadata};
1348 use futures::StreamExt;
1349 use std::time::Duration;
1350 use tokio_util::io::ReaderStream;
1351 use tokio_util::sync::CancellationToken;
1352
1353 fn test_producer_ctx() -> ProducerContext {
1354 ProducerContext::new()
1355 }
1356
1357 #[test]
1358 fn test_file_config_defaults() {
1359 let config = FileConfig::from_uri("file:/tmp/inbox").unwrap();
1360 assert_eq!(config.directory, "/tmp/inbox");
1361 assert_eq!(config.delay, Duration::from_millis(500));
1362 assert_eq!(config.initial_delay, Duration::from_millis(1000));
1363 assert!(!config.noop);
1364 assert!(!config.delete);
1365 assert_eq!(config.move_to, Some(".camel".to_string()));
1366 assert!(config.file_name.is_none());
1367 assert!(config.include.is_none());
1368 assert!(config.exclude.is_none());
1369 assert!(!config.recursive);
1370 assert_eq!(config.file_exist, FileExistStrategy::Override);
1371 assert!(config.temp_prefix.is_none());
1372 assert!(config.auto_create);
1373 assert_eq!(config.read_timeout, Duration::from_secs(30));
1375 assert_eq!(config.write_timeout, Duration::from_secs(30));
1376 }
1377
1378 #[test]
1379 fn test_file_config_consumer_options() {
1380 let config = FileConfig::from_uri(
1381 "file:/data/input?delay=1000&initialDelay=2000&noop=true&recursive=true&include=.*\\.csv"
1382 ).unwrap();
1383 assert_eq!(config.directory, "/data/input");
1384 assert_eq!(config.delay, Duration::from_millis(1000));
1385 assert_eq!(config.initial_delay, Duration::from_millis(2000));
1386 assert!(config.noop);
1387 assert!(config.recursive);
1388 assert_eq!(config.include, Some(".*\\.csv".to_string()));
1389 }
1390
1391 #[test]
1392 fn test_file_config_producer_options() {
1393 let config = FileConfig::from_uri(
1394 "file:/data/output?fileExist=Append&tempPrefix=.tmp&autoCreate=false&fileName=out.txt",
1395 )
1396 .unwrap();
1397 assert_eq!(config.file_exist, FileExistStrategy::Append);
1398 assert_eq!(config.temp_prefix, Some(".tmp".to_string()));
1399 assert!(!config.auto_create);
1400 assert_eq!(config.file_name, Some("out.txt".to_string()));
1401 }
1402
1403 #[test]
1404 fn test_file_config_delete_mode() {
1405 let config = FileConfig::from_uri("file:/tmp/inbox?delete=true").unwrap();
1406 assert!(config.delete);
1407 assert!(config.move_to.is_none());
1408 }
1409
1410 #[test]
1411 fn test_file_config_noop_mode() {
1412 let config = FileConfig::from_uri("file:/tmp/inbox?noop=true").unwrap();
1413 assert!(config.noop);
1414 assert!(config.move_to.is_none());
1415 }
1416
1417 #[test]
1418 fn test_file_config_wrong_scheme() {
1419 let result = FileConfig::from_uri("timer:tick");
1420 assert!(result.is_err());
1421 }
1422
1423 #[test]
1424 fn test_min_depth_greater_than_max_depth_rejected() {
1425 let result = FileConfig::from_uri("file:///tmp?minDepth=5&maxDepth=2");
1426 assert!(result.is_err());
1427 assert!(
1428 result
1429 .unwrap_err()
1430 .to_string()
1431 .contains("minDepth cannot be greater")
1432 );
1433 }
1434
1435 #[test]
1436 fn test_file_component_scheme() {
1437 let component = FileComponent::new();
1438 assert_eq!(component.scheme(), "file");
1439 }
1440
1441 #[test]
1442 fn test_file_component_creates_endpoint() {
1443 let component = FileComponent::new();
1444 let ctx = NoOpComponentContext;
1445 let endpoint = component.create_endpoint("file:/tmp/test", &ctx);
1446 assert!(endpoint.is_ok());
1447 }
1448
1449 #[tokio::test]
1454 async fn test_file_consumer_reads_files() {
1455 let dir = tempfile::tempdir().unwrap();
1456 let dir_path = dir.path().to_str().unwrap();
1457
1458 std::fs::write(dir.path().join("test1.txt"), "hello").unwrap();
1459 std::fs::write(dir.path().join("test2.txt"), "world").unwrap();
1460
1461 let component = FileComponent::new();
1462 let ctx = NoOpComponentContext;
1463 let endpoint = component
1464 .create_endpoint(
1465 &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100"),
1466 &ctx,
1467 )
1468 .unwrap();
1469 let mut consumer = endpoint.create_consumer(rt()).unwrap();
1470
1471 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1472 let token = CancellationToken::new();
1473 let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
1474
1475 tokio::spawn(async move {
1476 consumer.start(ctx).await.unwrap();
1477 });
1478
1479 let mut received = Vec::new();
1480 let timeout = tokio::time::timeout(Duration::from_secs(2), async {
1481 while let Some(envelope) = rx.recv().await {
1482 received.push(envelope.exchange);
1483 if received.len() == 2 {
1484 break;
1485 }
1486 }
1487 })
1488 .await;
1489 token.cancel();
1490
1491 assert!(timeout.is_ok(), "Should have received 2 exchanges");
1492 assert_eq!(received.len(), 2);
1493
1494 for ex in &received {
1495 assert!(ex.input.header("CamelFileName").is_some());
1496 assert!(ex.input.header("CamelFileNameOnly").is_some());
1497 assert!(ex.input.header("CamelFileAbsolutePath").is_some());
1498 assert!(ex.input.header("CamelFileLength").is_some());
1499 assert!(ex.input.header("CamelFileLastModified").is_some());
1500 }
1501 }
1502
1503 #[tokio::test]
1504 async fn noop_second_poll_does_not_re_emit_seen_files() {
1505 let dir = tempfile::tempdir().unwrap();
1506 let file_path = dir.path().join("test.txt");
1507 tokio::fs::write(&file_path, b"hello").await.unwrap();
1508
1509 let uri = format!(
1510 "file:{}?noop=true&initialDelay=0&delay=50",
1511 dir.path().display()
1512 );
1513 let config = FileConfig::from_uri(&uri).unwrap();
1514 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1515 let token = CancellationToken::new();
1516 let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
1517
1518 let filters = CompiledFilters::default();
1519 let mut seen = std::collections::HashSet::new();
1520 let in_process_locks = std::sync::Arc::new(DashMap::new());
1521 let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
1522
1523 poll_directory(
1524 &config,
1525 &ctx,
1526 &filters,
1527 &mut seen,
1528 &in_process_locks,
1529 &idempotent_repo,
1530 )
1531 .await
1532 .unwrap();
1533 assert!(rx.try_recv().is_ok(), "first poll should emit file");
1534 assert!(rx.try_recv().is_err(), "should only emit once");
1535
1536 poll_directory(
1537 &config,
1538 &ctx,
1539 &filters,
1540 &mut seen,
1541 &in_process_locks,
1542 &idempotent_repo,
1543 )
1544 .await
1545 .unwrap();
1546 assert!(
1547 rx.try_recv().is_err(),
1548 "second poll should not re-emit seen file"
1549 );
1550 }
1551
1552 #[tokio::test]
1553 async fn noop_new_files_picked_up_after_first_poll() {
1554 let dir = tempfile::tempdir().unwrap();
1555 let file1 = dir.path().join("a.txt");
1556 tokio::fs::write(&file1, b"a").await.unwrap();
1557
1558 let uri = format!(
1559 "file:{}?noop=true&initialDelay=0&delay=50",
1560 dir.path().display()
1561 );
1562 let config = FileConfig::from_uri(&uri).unwrap();
1563 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1564 let token = CancellationToken::new();
1565 let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
1566
1567 let filters = CompiledFilters::default();
1568 let mut seen = std::collections::HashSet::new();
1569 let in_process_locks = std::sync::Arc::new(DashMap::new());
1570 let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
1571
1572 poll_directory(
1573 &config,
1574 &ctx,
1575 &filters,
1576 &mut seen,
1577 &in_process_locks,
1578 &idempotent_repo,
1579 )
1580 .await
1581 .unwrap();
1582 let _ = rx.try_recv();
1583
1584 let file2 = dir.path().join("b.txt");
1585 tokio::fs::write(&file2, b"b").await.unwrap();
1586
1587 poll_directory(
1588 &config,
1589 &ctx,
1590 &filters,
1591 &mut seen,
1592 &in_process_locks,
1593 &idempotent_repo,
1594 )
1595 .await
1596 .unwrap();
1597 assert!(
1598 rx.try_recv().is_ok(),
1599 "b.txt should be emitted on second poll"
1600 );
1601 assert!(rx.try_recv().is_err(), "a.txt should not be re-emitted");
1602 }
1603
1604 #[tokio::test]
1605 async fn test_file_consumer_include_filter() {
1606 let dir = tempfile::tempdir().unwrap();
1607 let dir_path = dir.path().to_str().unwrap();
1608
1609 std::fs::write(dir.path().join("data.csv"), "a,b,c").unwrap();
1610 std::fs::write(dir.path().join("readme.txt"), "hello").unwrap();
1611
1612 let component = FileComponent::new();
1613 let ctx = NoOpComponentContext;
1614 let endpoint = component
1615 .create_endpoint(
1616 &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&include=.*\\.csv"),
1617 &ctx,
1618 )
1619 .unwrap();
1620 let mut consumer = endpoint.create_consumer(rt()).unwrap();
1621
1622 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1623 let token = CancellationToken::new();
1624 let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
1625
1626 tokio::spawn(async move {
1627 consumer.start(ctx).await.unwrap();
1628 });
1629
1630 let mut received = Vec::new();
1631 let _ = tokio::time::timeout(Duration::from_millis(500), async {
1632 while let Some(envelope) = rx.recv().await {
1633 received.push(envelope.exchange);
1634 if received.len() == 1 {
1635 break;
1636 }
1637 }
1638 })
1639 .await;
1640 token.cancel();
1641
1642 assert_eq!(received.len(), 1);
1643 let name = received[0]
1644 .input
1645 .header("CamelFileNameOnly")
1646 .and_then(|v| v.as_str())
1647 .unwrap();
1648 assert_eq!(name, "data.csv");
1649 }
1650
1651 #[tokio::test]
1652 async fn test_file_consumer_delete_mode() {
1653 let dir = tempfile::tempdir().unwrap();
1654 let dir_path = dir.path().to_str().unwrap();
1655
1656 std::fs::write(dir.path().join("deleteme.txt"), "bye").unwrap();
1657
1658 let component = FileComponent::new();
1659 let ctx = NoOpComponentContext;
1660 let endpoint = component
1661 .create_endpoint(
1662 &format!("file:{dir_path}?delete=true&initialDelay=0&delay=100"),
1663 &ctx,
1664 )
1665 .unwrap();
1666 let mut consumer = endpoint.create_consumer(rt()).unwrap();
1667
1668 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1669 let token = CancellationToken::new();
1670 let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
1671
1672 tokio::spawn(async move {
1673 consumer.start(ctx).await.unwrap();
1674 });
1675
1676 let _ = tokio::time::timeout(Duration::from_millis(500), async { rx.recv().await }).await;
1677 token.cancel();
1678
1679 tokio::time::sleep(Duration::from_millis(100)).await;
1680
1681 assert!(
1682 !dir.path().join("deleteme.txt").exists(),
1683 "File should be deleted"
1684 );
1685 }
1686
1687 #[tokio::test]
1688 async fn test_file_consumer_move_mode() {
1689 let dir = tempfile::tempdir().unwrap();
1690 tokio::fs::write(dir.path().join("moveme.txt"), b"data")
1691 .await
1692 .unwrap();
1693
1694 let uri = format!("file:{}?initialDelay=0&delay=50", dir.path().display());
1695 let config = FileConfig::from_uri(&uri).unwrap();
1696 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1697 let token = CancellationToken::new();
1698 let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
1699
1700 let filters = CompiledFilters::default();
1701 let mut seen = std::collections::HashSet::new();
1702 let in_process_locks = std::sync::Arc::new(DashMap::new());
1703 let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
1704
1705 poll_directory(
1706 &config,
1707 &ctx,
1708 &filters,
1709 &mut seen,
1710 &in_process_locks,
1711 &idempotent_repo,
1712 )
1713 .await
1714 .unwrap();
1715
1716 let ex = rx.try_recv().expect("should receive exchange");
1717 drop(ex);
1718
1719 assert!(
1720 !dir.path().join("moveme.txt").exists(),
1721 "Original file should be gone"
1722 );
1723 assert!(
1724 dir.path().join(".camel").join("moveme.txt").exists(),
1725 "File should be in .camel/"
1726 );
1727 }
1728
1729 #[tokio::test]
1730 async fn test_file_consumer_respects_cancellation() {
1731 let dir = tempfile::tempdir().unwrap();
1732 let dir_path = dir.path().to_str().unwrap();
1733
1734 let component = FileComponent::new();
1735 let ctx = NoOpComponentContext;
1736 let endpoint = component
1737 .create_endpoint(&format!("file:{dir_path}?initialDelay=0&delay=50"), &ctx)
1738 .unwrap();
1739 let mut consumer = endpoint.create_consumer(rt()).unwrap();
1740
1741 let (tx, _rx) = tokio::sync::mpsc::channel(16);
1742 let token = CancellationToken::new();
1743 let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
1744
1745 let handle = tokio::spawn(async move {
1746 consumer.start(ctx).await.unwrap();
1747 });
1748
1749 tokio::time::sleep(Duration::from_millis(150)).await;
1750 token.cancel();
1751
1752 let result = tokio::time::timeout(Duration::from_secs(1), handle).await;
1753 assert!(
1754 result.is_ok(),
1755 "Consumer should have stopped after cancellation"
1756 );
1757 }
1758
1759 #[tokio::test]
1764 async fn test_file_producer_writes_file() {
1765 use tower::ServiceExt;
1766
1767 let dir = tempfile::tempdir().unwrap();
1768 let dir_path = dir.path().to_str().unwrap();
1769
1770 let component = FileComponent::new();
1771 let ctx = NoOpComponentContext;
1772 let endpoint = component
1773 .create_endpoint(&format!("file:{dir_path}"), &ctx)
1774 .unwrap();
1775 let ctx = test_producer_ctx();
1776 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1777
1778 let mut exchange = Exchange::new(Message::new("file content"));
1779 exchange.input.set_header(
1780 "CamelFileName",
1781 serde_json::Value::String("output.txt".to_string()),
1782 );
1783
1784 let result = producer.oneshot(exchange).await.unwrap();
1785
1786 let content = std::fs::read_to_string(dir.path().join("output.txt")).unwrap();
1787 assert_eq!(content, "file content");
1788
1789 assert!(result.input.header("CamelFileNameProduced").is_some());
1790 }
1791
1792 #[tokio::test]
1793 async fn test_file_producer_auto_create_dirs() {
1794 use tower::ServiceExt;
1795
1796 let dir = tempfile::tempdir().unwrap();
1797 let dir_path = dir.path().to_str().unwrap();
1798
1799 let component = FileComponent::new();
1800 let ctx = NoOpComponentContext;
1801 let endpoint = component
1802 .create_endpoint(&format!("file:{dir_path}/sub/dir"), &ctx)
1803 .unwrap();
1804 let ctx = test_producer_ctx();
1805 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1806
1807 let mut exchange = Exchange::new(Message::new("nested"));
1808 exchange.input.set_header(
1809 "CamelFileName",
1810 serde_json::Value::String("file.txt".to_string()),
1811 );
1812
1813 producer.oneshot(exchange).await.unwrap();
1814
1815 assert!(dir.path().join("sub/dir/file.txt").exists());
1816 }
1817
1818 #[tokio::test]
1819 async fn test_file_producer_file_exist_fail() {
1820 use tower::ServiceExt;
1821
1822 let dir = tempfile::tempdir().unwrap();
1823 let dir_path = dir.path().to_str().unwrap();
1824
1825 std::fs::write(dir.path().join("existing.txt"), "old").unwrap();
1826
1827 let component = FileComponent::new();
1828 let ctx = NoOpComponentContext;
1829 let endpoint = component
1830 .create_endpoint(&format!("file:{dir_path}?fileExist=Fail"), &ctx)
1831 .unwrap();
1832 let ctx = test_producer_ctx();
1833 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1834
1835 let mut exchange = Exchange::new(Message::new("new"));
1836 exchange.input.set_header(
1837 "CamelFileName",
1838 serde_json::Value::String("existing.txt".to_string()),
1839 );
1840
1841 let result = producer.oneshot(exchange).await;
1842 assert!(
1843 result.is_err(),
1844 "Should fail when file exists with Fail strategy"
1845 );
1846 }
1847
1848 #[tokio::test]
1849 async fn test_file_producer_file_exist_append() {
1850 use tower::ServiceExt;
1851
1852 let dir = tempfile::tempdir().unwrap();
1853 let dir_path = dir.path().to_str().unwrap();
1854
1855 std::fs::write(dir.path().join("append.txt"), "old").unwrap();
1856
1857 let component = FileComponent::new();
1858 let ctx = NoOpComponentContext;
1859 let endpoint = component
1860 .create_endpoint(&format!("file:{dir_path}?fileExist=Append"), &ctx)
1861 .unwrap();
1862 let ctx = test_producer_ctx();
1863 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1864
1865 let mut exchange = Exchange::new(Message::new("new"));
1866 exchange.input.set_header(
1867 "CamelFileName",
1868 serde_json::Value::String("append.txt".to_string()),
1869 );
1870
1871 producer.oneshot(exchange).await.unwrap();
1872
1873 let content = std::fs::read_to_string(dir.path().join("append.txt")).unwrap();
1874 assert_eq!(content, "oldnew");
1875 }
1876
1877 #[tokio::test]
1878 async fn test_file_producer_temp_prefix() {
1879 use tower::ServiceExt;
1880
1881 let dir = tempfile::tempdir().unwrap();
1882 let dir_path = dir.path().to_str().unwrap();
1883
1884 let component = FileComponent::new();
1885 let ctx = NoOpComponentContext;
1886 let endpoint = component
1887 .create_endpoint(&format!("file:{dir_path}?tempPrefix=.tmp"), &ctx)
1888 .unwrap();
1889 let ctx = test_producer_ctx();
1890 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1891
1892 let mut exchange = Exchange::new(Message::new("atomic write"));
1893 exchange.input.set_header(
1894 "CamelFileName",
1895 serde_json::Value::String("final.txt".to_string()),
1896 );
1897
1898 producer.oneshot(exchange).await.unwrap();
1899
1900 assert!(dir.path().join("final.txt").exists());
1901 assert!(!dir.path().join(".tmpfinal.txt").exists());
1902 let content = std::fs::read_to_string(dir.path().join("final.txt")).unwrap();
1903 assert_eq!(content, "atomic write");
1904 }
1905
1906 #[tokio::test]
1907 async fn test_file_producer_uses_filename_option() {
1908 use tower::ServiceExt;
1909
1910 let dir = tempfile::tempdir().unwrap();
1911 let dir_path = dir.path().to_str().unwrap();
1912
1913 let component = FileComponent::new();
1914 let ctx = NoOpComponentContext;
1915 let endpoint = component
1916 .create_endpoint(&format!("file:{dir_path}?fileName=fixed.txt"), &ctx)
1917 .unwrap();
1918 let ctx = test_producer_ctx();
1919 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1920
1921 let exchange = Exchange::new(Message::new("content"));
1922
1923 producer.oneshot(exchange).await.unwrap();
1924 assert!(dir.path().join("fixed.txt").exists());
1925 }
1926
1927 #[tokio::test]
1928 async fn test_file_producer_no_filename_errors() {
1929 use tower::ServiceExt;
1930
1931 let dir = tempfile::tempdir().unwrap();
1932 let dir_path = dir.path().to_str().unwrap();
1933
1934 let component = FileComponent::new();
1935 let ctx = NoOpComponentContext;
1936 let endpoint = component
1937 .create_endpoint(&format!("file:{dir_path}"), &ctx)
1938 .unwrap();
1939 let ctx = test_producer_ctx();
1940 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1941
1942 let exchange = Exchange::new(Message::new("content"));
1943
1944 let result = producer.oneshot(exchange).await;
1945 assert!(result.is_err(), "Should error when no filename is provided");
1946 }
1947
1948 #[tokio::test]
1953 async fn test_file_producer_rejects_path_traversal_parent_directory() {
1954 use tower::ServiceExt;
1955
1956 let dir = tempfile::tempdir().unwrap();
1957 let dir_path = dir.path().to_str().unwrap();
1958
1959 std::fs::create_dir(dir.path().join("subdir")).unwrap();
1961 std::fs::write(dir.path().join("secret.txt"), "secret").unwrap();
1962
1963 let component = FileComponent::new();
1964 let ctx = NoOpComponentContext;
1965 let endpoint = component
1966 .create_endpoint(&format!("file:{dir_path}/subdir"), &ctx)
1967 .unwrap();
1968 let ctx = test_producer_ctx();
1969 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1970
1971 let mut exchange = Exchange::new(Message::new("malicious"));
1972 exchange.input.set_header(
1973 "CamelFileName",
1974 serde_json::Value::String("../secret.txt".to_string()),
1975 );
1976
1977 let result = producer.oneshot(exchange).await;
1978 assert!(result.is_err(), "Should reject path traversal attempt");
1979
1980 let err = result.unwrap_err();
1981 assert!(
1982 err.to_string().contains("outside"),
1983 "Error should mention path is outside base directory"
1984 );
1985 }
1986
1987 #[tokio::test]
1988 async fn test_file_producer_rejects_absolute_path_outside_base() {
1989 use tower::ServiceExt;
1990
1991 let dir = tempfile::tempdir().unwrap();
1992 let dir_path = dir.path().to_str().unwrap();
1993
1994 let component = FileComponent::new();
1995 let ctx = NoOpComponentContext;
1996 let endpoint = component
1997 .create_endpoint(&format!("file:{dir_path}"), &ctx)
1998 .unwrap();
1999 let ctx = test_producer_ctx();
2000 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2001
2002 let mut exchange = Exchange::new(Message::new("malicious"));
2003 exchange.input.set_header(
2004 "CamelFileName",
2005 serde_json::Value::String("/etc/passwd".to_string()),
2006 );
2007
2008 let result = producer.oneshot(exchange).await;
2009 assert!(result.is_err(), "Should reject absolute path outside base");
2010 }
2011
2012 #[tokio::test]
2013 async fn test_file_producer_does_not_create_dirs_before_path_validation() {
2014 use tower::ServiceExt;
2015
2016 let dir = tempfile::tempdir().unwrap();
2017 let dir_path = dir.path().to_str().unwrap();
2018
2019 let component = FileComponent::new();
2020 let ctx = NoOpComponentContext;
2021 let endpoint = component
2022 .create_endpoint(&format!("file:{dir_path}"), &ctx)
2023 .unwrap();
2024 let ctx = test_producer_ctx();
2025 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2026
2027 let outside_parent = dir.path().parent().unwrap().join("escaped-create-dir");
2028 if outside_parent.exists() {
2029 std::fs::remove_dir_all(&outside_parent).unwrap();
2030 }
2031
2032 let mut exchange = Exchange::new(Message::new("malicious"));
2033 exchange.input.set_header(
2034 "CamelFileName",
2035 serde_json::Value::String("../escaped-create-dir/file.txt".to_string()),
2036 );
2037
2038 let result = producer.oneshot(exchange).await;
2039 assert!(result.is_err(), "Should reject traversal path");
2040 assert!(
2041 !outside_parent.exists(),
2042 "Must not create outside directories before path validation"
2043 );
2044 }
2045
2046 #[cfg(unix)]
2047 #[tokio::test]
2048 async fn test_list_files_skips_symlink_cycle() {
2049 use std::os::unix::fs as unix_fs;
2050
2051 let dir = tempfile::tempdir().unwrap();
2052 let nested = dir.path().join("nested");
2053 std::fs::create_dir_all(&nested).unwrap();
2054 std::fs::write(nested.join("a.txt"), "x").unwrap();
2055 unix_fs::symlink(dir.path(), nested.join("loop")).unwrap();
2056
2057 let files = list_files(dir.path(), true).await.unwrap();
2058 assert_eq!(files.iter().filter(|p| p.ends_with("a.txt")).count(), 1);
2059 }
2060
2061 #[tokio::test]
2066 #[ignore] async fn test_large_file_streaming_constant_memory() {
2068 use std::io::Write;
2069 use tempfile::NamedTempFile;
2070
2071 let mut temp_file = NamedTempFile::new().unwrap();
2073 let file_size = 150 * 1024 * 1024; let chunk = vec![b'X'; 1024 * 1024]; for _ in 0..150 {
2077 temp_file.write_all(&chunk).unwrap();
2078 }
2079 temp_file.flush().unwrap();
2080
2081 let dir = temp_file.path().parent().unwrap();
2082 let dir_path = dir.to_str().unwrap();
2083 let file_name = temp_file
2084 .path()
2085 .file_name()
2086 .unwrap()
2087 .to_str()
2088 .unwrap()
2089 .to_string();
2090
2091 let component = FileComponent::new();
2093 let component_ctx = NoOpComponentContext;
2094 let endpoint = component
2095 .create_endpoint(
2096 &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&fileName={file_name}"),
2097 &component_ctx,
2098 )
2099 .unwrap();
2100 let mut consumer = endpoint.create_consumer(rt()).unwrap();
2101
2102 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
2103 let token = CancellationToken::new();
2104 let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
2105
2106 tokio::spawn(async move {
2107 let _ = consumer.start(ctx).await;
2108 });
2109
2110 let exchange = tokio::time::timeout(Duration::from_secs(5), async {
2111 rx.recv().await.unwrap().exchange
2112 })
2113 .await
2114 .expect("Should receive exchange");
2115 token.cancel();
2116
2117 assert!(matches!(exchange.input.body, Body::Stream(_)));
2119
2120 if let Body::Stream(ref stream_body) = exchange.input.body {
2122 assert!(stream_body.metadata.size_hint.is_some());
2123 let size = stream_body.metadata.size_hint.unwrap();
2124 assert_eq!(size, file_size as u64);
2125 }
2126
2127 if let Body::Stream(stream_body) = exchange.input.body {
2129 let body = Body::Stream(stream_body);
2130 let result = body.into_bytes(100 * 1024 * 1024).await;
2131 assert!(result.is_err());
2132 }
2133
2134 let component2 = FileComponent::new();
2137 let endpoint2 = component2
2138 .create_endpoint(
2139 &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&fileName={file_name}"),
2140 &component_ctx,
2141 )
2142 .unwrap();
2143 let mut consumer2 = endpoint2.create_consumer(rt()).unwrap();
2144
2145 let (tx2, mut rx2) = tokio::sync::mpsc::channel(16);
2146 let token2 = CancellationToken::new();
2147 let ctx2 = ConsumerContext::new(tx2, token2.clone(), "file-test-route-2".to_string());
2148
2149 tokio::spawn(async move {
2150 let _ = consumer2.start(ctx2).await;
2151 });
2152
2153 let exchange2 = tokio::time::timeout(Duration::from_secs(5), async {
2154 rx2.recv().await.unwrap().exchange
2155 })
2156 .await
2157 .expect("Should receive exchange");
2158 token2.cancel();
2159
2160 if let Body::Stream(stream_body) = exchange2.input.body {
2161 let mut stream_lock = stream_body.stream.lock().await;
2162 let mut stream = stream_lock.take().unwrap();
2163
2164 if let Some(chunk_result) = stream.next().await {
2166 let chunk = chunk_result.unwrap();
2167 assert!(!chunk.is_empty());
2168 assert!(chunk.len() < file_size);
2169 }
2171 }
2172 }
2173
2174 #[tokio::test]
2179 async fn test_producer_writes_stream_body() {
2180 let dir = tempfile::tempdir().unwrap();
2181 let dir_path = dir.path().to_str().unwrap();
2182 let uri = format!("file:{dir_path}?fileName=out.txt");
2183
2184 let component = FileComponent::new();
2185 let ctx = NoOpComponentContext;
2186 let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2187 let producer = endpoint
2188 .create_producer(rt(), &test_producer_ctx())
2189 .unwrap();
2190
2191 let chunks: Vec<Result<Bytes, CamelError>> = vec![
2192 Ok(Bytes::from("hello ")),
2193 Ok(Bytes::from("streaming ")),
2194 Ok(Bytes::from("world")),
2195 ];
2196 let stream = futures::stream::iter(chunks);
2197 let body = Body::Stream(StreamBody {
2198 stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2199 metadata: StreamMetadata {
2200 size_hint: None,
2201 content_type: None,
2202 origin: None,
2203 },
2204 });
2205
2206 let exchange = Exchange::new(Message::new(body));
2207 tower::ServiceExt::oneshot(producer, exchange)
2208 .await
2209 .unwrap();
2210
2211 let content = tokio::fs::read_to_string(format!("{dir_path}/out.txt"))
2212 .await
2213 .unwrap();
2214 assert_eq!(content, "hello streaming world");
2215 }
2216
2217 #[tokio::test]
2218 async fn test_producer_stream_atomic_no_partial_on_error() {
2219 let dir = tempfile::tempdir().unwrap();
2221 let dir_path = dir.path().to_str().unwrap();
2222 let uri = format!("file:{dir_path}?fileName=out.txt");
2223
2224 let component = FileComponent::new();
2225 let ctx = NoOpComponentContext;
2226 let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2227 let producer = endpoint
2228 .create_producer(rt(), &test_producer_ctx())
2229 .unwrap();
2230
2231 let chunks: Vec<Result<Bytes, CamelError>> = vec![
2232 Ok(Bytes::from("partial")),
2233 Err(CamelError::ProcessorError(
2234 "simulated stream error".to_string(),
2235 )),
2236 ];
2237 let stream = futures::stream::iter(chunks);
2238 let body = Body::Stream(StreamBody {
2239 stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2240 metadata: StreamMetadata {
2241 size_hint: None,
2242 content_type: None,
2243 origin: None,
2244 },
2245 });
2246
2247 let exchange = Exchange::new(Message::new(body));
2248 let result = tower::ServiceExt::oneshot(producer, exchange).await;
2249 assert!(
2250 result.is_err(),
2251 "expected error when stream fails mid-write"
2252 );
2253
2254 assert!(
2256 !std::path::Path::new(&format!("{dir_path}/out.txt")).exists(),
2257 "partial file must not exist after failed write"
2258 );
2259
2260 assert!(
2262 !std::path::Path::new(&format!("{dir_path}/.tmp.out.txt")).exists(),
2263 "temp file must be cleaned up after failed write"
2264 );
2265 }
2266
2267 #[tokio::test]
2268 async fn test_producer_stream_append() {
2269 let dir = tempfile::tempdir().unwrap();
2270 let dir_path = dir.path().to_str().unwrap();
2271 let target = format!("{dir_path}/out.txt");
2272
2273 tokio::fs::write(&target, b"line1\n").await.unwrap();
2275
2276 let uri = format!("file:{dir_path}?fileName=out.txt&fileExist=Append");
2277 let component = FileComponent::new();
2278 let ctx = NoOpComponentContext;
2279 let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2280 let producer = endpoint
2281 .create_producer(rt(), &test_producer_ctx())
2282 .unwrap();
2283
2284 let chunks: Vec<Result<Bytes, CamelError>> = vec![Ok(Bytes::from("line2\n"))];
2285 let stream = futures::stream::iter(chunks);
2286 let body = Body::Stream(StreamBody {
2287 stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2288 metadata: StreamMetadata {
2289 size_hint: None,
2290 content_type: None,
2291 origin: None,
2292 },
2293 });
2294
2295 let exchange = Exchange::new(Message::new(body));
2296 tower::ServiceExt::oneshot(producer, exchange)
2297 .await
2298 .unwrap();
2299
2300 let content = tokio::fs::read_to_string(&target).await.unwrap();
2301 assert_eq!(content, "line1\nline2\n");
2302 }
2303
2304 #[tokio::test]
2305 async fn test_producer_stream_append_partial_on_error() {
2306 let dir = tempfile::tempdir().unwrap();
2309 let dir_path = dir.path().to_str().unwrap();
2310 let target = format!("{dir_path}/out.txt");
2311
2312 tokio::fs::write(&target, b"initial\n").await.unwrap();
2314
2315 let uri = format!("file:{dir_path}?fileName=out.txt&fileExist=Append");
2316 let component = FileComponent::new();
2317 let ctx = NoOpComponentContext;
2318 let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2319 let producer = endpoint
2320 .create_producer(rt(), &test_producer_ctx())
2321 .unwrap();
2322
2323 let chunks: Vec<Result<Bytes, CamelError>> = vec![
2325 Ok(Bytes::from("partial-")), Err(CamelError::ProcessorError("stream error".to_string())), Ok(Bytes::from("never-written")), ];
2329 let stream = futures::stream::iter(chunks);
2330 let body = Body::Stream(StreamBody {
2331 stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2332 metadata: StreamMetadata {
2333 size_hint: None,
2334 content_type: None,
2335 origin: None,
2336 },
2337 });
2338
2339 let exchange = Exchange::new(Message::new(body));
2340 let result = tower::ServiceExt::oneshot(producer, exchange).await;
2341
2342 assert!(
2344 result.is_err(),
2345 "expected error when stream fails during append"
2346 );
2347
2348 let content = tokio::fs::read_to_string(&target).await.unwrap();
2350 assert_eq!(
2351 content, "initial\npartial-",
2352 "append leaves partial data on stream error (non-atomic by nature)"
2353 );
2354 }
2355
2356 #[tokio::test]
2357 async fn test_producer_stream_already_consumed_errors() {
2358 let dir = tempfile::tempdir().unwrap();
2359 let dir_path = dir.path().to_str().unwrap();
2360 let uri = format!("file:{dir_path}?fileName=out.txt");
2361
2362 let component = FileComponent::new();
2363 let ctx = NoOpComponentContext;
2364 let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2365 let producer = endpoint
2366 .create_producer(rt(), &test_producer_ctx())
2367 .unwrap();
2368
2369 type MaybeStream = std::sync::Arc<
2371 tokio::sync::Mutex<
2372 Option<
2373 std::pin::Pin<
2374 Box<dyn futures::Stream<Item = Result<Bytes, CamelError>> + Send>,
2375 >,
2376 >,
2377 >,
2378 >;
2379 let arc: MaybeStream = std::sync::Arc::new(tokio::sync::Mutex::new(None));
2380 let body = Body::Stream(StreamBody {
2381 stream: arc,
2382 metadata: StreamMetadata {
2383 size_hint: None,
2384 content_type: None,
2385 origin: None,
2386 },
2387 });
2388
2389 let exchange = Exchange::new(Message::new(body));
2390 let result = tower::ServiceExt::oneshot(producer, exchange).await;
2391 assert!(
2392 result.is_err(),
2393 "expected error for already-consumed stream"
2394 );
2395 }
2396
2397 #[test]
2402 fn test_global_config_applied_to_endpoint() {
2403 let global = FileGlobalConfig::default()
2405 .with_delay_ms(2000)
2406 .with_initial_delay_ms(5000)
2407 .with_read_timeout_ms(60_000)
2408 .with_write_timeout_ms(45_000);
2409 let component = FileComponent::with_config(global);
2410 let ctx = NoOpComponentContext;
2411 let endpoint = component.create_endpoint("file:/tmp/inbox", &ctx).unwrap();
2413 let mut config = FileConfig::from_uri("file:/tmp/inbox").unwrap();
2416 let global2 = FileGlobalConfig::default()
2417 .with_delay_ms(2000)
2418 .with_initial_delay_ms(5000)
2419 .with_read_timeout_ms(60_000)
2420 .with_write_timeout_ms(45_000);
2421 config.apply_global_defaults(&global2);
2422 assert_eq!(config.delay, Duration::from_millis(2000));
2423 assert_eq!(config.initial_delay, Duration::from_millis(5000));
2424 assert_eq!(config.read_timeout, Duration::from_millis(60_000));
2425 assert_eq!(config.write_timeout, Duration::from_millis(45_000));
2426 let _ = endpoint; }
2429
2430 #[test]
2431 fn test_uri_param_wins_over_global_config() {
2432 let mut config =
2434 FileConfig::from_uri("file:/tmp/inbox?delay=1000&initialDelay=2000").unwrap();
2435 let global = FileGlobalConfig::default()
2437 .with_delay_ms(3000)
2438 .with_initial_delay_ms(4000);
2439 config.apply_global_defaults(&global);
2440 assert_eq!(config.delay, Duration::from_millis(1000));
2442 assert_eq!(config.initial_delay, Duration::from_millis(2000));
2444 assert_eq!(config.read_timeout, Duration::from_millis(30_000));
2447 }
2448
2449 #[tokio::test]
2450 async fn test_file_producer_filename_simple_language_from_header() {
2451 use tower::ServiceExt;
2452
2453 let dir = tempfile::tempdir().unwrap();
2454 let dir_path = dir.path().to_str().unwrap();
2455
2456 let component = FileComponent::new();
2457 let ctx = NoOpComponentContext;
2458 let endpoint = component
2459 .create_endpoint(&format!("file:{dir_path}"), &ctx)
2460 .unwrap();
2461 let ctx = test_producer_ctx();
2462 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2463
2464 let mut exchange = Exchange::new(Message::new("content"));
2465 exchange
2466 .input
2467 .set_header("CamelTimerCounter", serde_json::Value::Number(42.into()));
2468 exchange.input.set_header(
2469 "CamelFileName",
2470 serde_json::Value::String("test-${header.CamelTimerCounter}.txt".to_string()),
2471 );
2472
2473 producer.oneshot(exchange).await.unwrap();
2474
2475 assert!(
2476 dir.path().join("test-42.txt").exists(),
2477 "fileName should have been evaluated from Simple Language expression"
2478 );
2479 let content = std::fs::read_to_string(dir.path().join("test-42.txt")).unwrap();
2480 assert_eq!(content, "content");
2481 }
2482
2483 #[tokio::test]
2484 async fn test_file_producer_filename_simple_language_from_uri_param() {
2485 use tower::ServiceExt;
2486
2487 let dir = tempfile::tempdir().unwrap();
2488 let dir_path = dir.path().to_str().unwrap();
2489
2490 let component = FileComponent::new();
2491 let ctx = NoOpComponentContext;
2492 let endpoint = component
2493 .create_endpoint(
2494 &format!("file:{dir_path}?fileName=msg-${{header.id}}.dat"),
2495 &ctx,
2496 )
2497 .unwrap();
2498 let ctx = test_producer_ctx();
2499 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2500
2501 let mut exchange = Exchange::new(Message::new("data"));
2502 exchange
2503 .input
2504 .set_header("id", serde_json::Value::String("abc".to_string()));
2505
2506 producer.oneshot(exchange).await.unwrap();
2507
2508 assert!(
2509 dir.path().join("msg-abc.dat").exists(),
2510 "fileName URI param should have been evaluated from Simple Language expression"
2511 );
2512 }
2513
2514 #[tokio::test]
2515 async fn test_file_producer_filename_literal_without_expression() {
2516 use tower::ServiceExt;
2517
2518 let dir = tempfile::tempdir().unwrap();
2519 let dir_path = dir.path().to_str().unwrap();
2520
2521 let component = FileComponent::new();
2522 let ctx = NoOpComponentContext;
2523 let endpoint = component
2524 .create_endpoint(&format!("file:{dir_path}?fileName=plain.txt"), &ctx)
2525 .unwrap();
2526 let ctx = test_producer_ctx();
2527 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2528
2529 let exchange = Exchange::new(Message::new("data"));
2530 producer.oneshot(exchange).await.unwrap();
2531
2532 assert!(
2533 dir.path().join("plain.txt").exists(),
2534 "literal fileName without expressions should still work"
2535 );
2536 }
2537
2538 #[test]
2543 fn test_rejects_path_traversal_in_move_to() {
2544 let result = FileConfig::from_uri("file:/tmp/inbox?move=../etc/passwd");
2545 assert!(result.is_err(), "should reject path traversal in move_to");
2546 let err = result.unwrap_err().to_string();
2547 assert!(
2548 err.contains("path traversal"),
2549 "error should mention path traversal: {err}"
2550 );
2551 }
2552
2553 #[test]
2554 fn test_rejects_absolute_move_to() {
2555 let result = FileConfig::from_uri("file:/tmp/inbox?move=/tmp/outside");
2556 assert!(result.is_err(), "should reject absolute move_to");
2557 let err = result.unwrap_err().to_string();
2558 assert!(
2559 err.contains("relative path") || err.contains("Invalid URI"),
2560 "error should mention invalid move_to path: {err}"
2561 );
2562 }
2563
2564 #[test]
2565 fn test_rejects_path_traversal_in_temp_prefix() {
2566 let result = FileConfig::from_uri("file:/tmp/inbox?tempPrefix=../tmp");
2567 assert!(
2568 result.is_err(),
2569 "should reject path traversal in temp_prefix"
2570 );
2571 let err = result.unwrap_err().to_string();
2572 assert!(
2573 err.contains("path traversal"),
2574 "error should mention path traversal: {err}"
2575 );
2576 }
2577
2578 #[test]
2579 fn test_rejects_temp_prefix_with_path_separator() {
2580 let result = FileConfig::from_uri("file:/tmp/inbox?tempPrefix=tmp/sub");
2581 assert!(result.is_err(), "should reject temp_prefix with separator");
2582 let err = result.unwrap_err().to_string();
2583 assert!(
2584 err.contains("plain filename prefix"),
2585 "error should mention plain filename prefix restriction: {err}"
2586 );
2587 }
2588
2589 #[test]
2590 fn test_rejects_absolute_temp_prefix() {
2591 let result = FileConfig::from_uri("file:/tmp/inbox?tempPrefix=/tmp/");
2592 assert!(result.is_err(), "should reject absolute temp_prefix");
2593 let err = result.unwrap_err().to_string();
2594 assert!(
2595 err.contains("plain filename prefix"),
2596 "error should mention plain filename prefix restriction: {err}"
2597 );
2598 }
2599
2600 #[test]
2601 fn test_rejects_null_byte_in_temp_prefix() {
2602 let config = FileConfig {
2603 directory: "/tmp/inbox".into(),
2604 delay: Duration::from_millis(500),
2605 delay_ms: 500,
2606 initial_delay: Duration::from_millis(1000),
2607 initial_delay_ms: 1000,
2608 noop: false,
2609 delete: false,
2610 move_to: None,
2611 file_name: Some("ok.txt".into()),
2612 include: None,
2613 exclude: None,
2614 ant_include: None,
2615 ant_exclude: None,
2616 include_ext: None,
2617 exclude_ext: None,
2618 recursive: false,
2619 file_exist: FileExistStrategy::Override,
2620 read_lock_strategy: ReadLockStrategy::None,
2621 idempotent_key: IdempotentKey::None,
2622 done_file_name: None,
2623 charset: None,
2624 temp_prefix: Some("tmp\0".into()),
2625 auto_create: true,
2626 starting_directory_must_exist: false,
2627 read_timeout: Duration::from_millis(30_000),
2628 read_timeout_ms: 30_000,
2629 write_timeout: Duration::from_millis(30_000),
2630 write_timeout_ms: 30_000,
2631 max_depth: usize::MAX,
2632 min_depth: 0,
2633 max_messages_per_poll: 0,
2634 eager_max_messages_per_poll: true,
2635 shuffle: false,
2636 sort_spec: None,
2637 };
2638
2639 let result = config.validate();
2640 assert!(result.is_err(), "should reject null byte in temp_prefix");
2641 }
2642
2643 #[test]
2644 fn test_rejects_null_byte_in_filename() {
2645 let config = FileConfig {
2648 directory: "/tmp/inbox".into(),
2649 delay: Duration::from_millis(500),
2650 delay_ms: 500,
2651 initial_delay: Duration::from_millis(1000),
2652 initial_delay_ms: 1000,
2653 noop: false,
2654 delete: false,
2655 move_to: None,
2656 file_name: Some("foo\0bar".into()),
2657 include: None,
2658 exclude: None,
2659 ant_include: None,
2660 ant_exclude: None,
2661 include_ext: None,
2662 exclude_ext: None,
2663 recursive: false,
2664 file_exist: FileExistStrategy::Override,
2665 read_lock_strategy: ReadLockStrategy::None,
2666 idempotent_key: IdempotentKey::None,
2667 done_file_name: None,
2668 charset: None,
2669 temp_prefix: None,
2670 auto_create: true,
2671 starting_directory_must_exist: false,
2672 read_timeout: Duration::from_millis(30_000),
2673 read_timeout_ms: 30_000,
2674 write_timeout: Duration::from_millis(30_000),
2675 write_timeout_ms: 30_000,
2676 max_depth: usize::MAX,
2677 min_depth: 0,
2678 max_messages_per_poll: 0,
2679 eager_max_messages_per_poll: true,
2680 shuffle: false,
2681 sort_spec: None,
2682 };
2683 let result = config.validate();
2684 assert!(result.is_err(), "should reject null byte in filename");
2685 let err = result.unwrap_err().to_string();
2686 assert!(
2687 err.contains("null"),
2688 "error should mention null bytes: {err}"
2689 );
2690 }
2691
2692 #[test]
2693 fn test_rejects_empty_filename() {
2694 let config = FileConfig {
2695 directory: "/tmp/inbox".into(),
2696 delay: Duration::from_millis(500),
2697 delay_ms: 500,
2698 initial_delay: Duration::from_millis(1000),
2699 initial_delay_ms: 1000,
2700 noop: false,
2701 delete: false,
2702 move_to: None,
2703 file_name: Some("".into()),
2704 include: None,
2705 exclude: None,
2706 ant_include: None,
2707 ant_exclude: None,
2708 include_ext: None,
2709 exclude_ext: None,
2710 recursive: false,
2711 file_exist: FileExistStrategy::Override,
2712 read_lock_strategy: ReadLockStrategy::None,
2713 idempotent_key: IdempotentKey::None,
2714 done_file_name: None,
2715 charset: None,
2716 temp_prefix: None,
2717 auto_create: true,
2718 starting_directory_must_exist: false,
2719 read_timeout: Duration::from_millis(30_000),
2720 read_timeout_ms: 30_000,
2721 write_timeout: Duration::from_millis(30_000),
2722 write_timeout_ms: 30_000,
2723 max_depth: usize::MAX,
2724 min_depth: 0,
2725 max_messages_per_poll: 0,
2726 eager_max_messages_per_poll: true,
2727 shuffle: false,
2728 sort_spec: None,
2729 };
2730 let result = config.validate();
2731 assert!(result.is_err(), "should reject empty filename");
2732 let err = result.unwrap_err().to_string();
2733 assert!(
2734 err.contains("empty") || err.contains("must not"),
2735 "error should mention empty: {err}"
2736 );
2737 }
2738
2739 #[test]
2740 fn test_rejects_nonexistent_directory_when_starting_directory_must_exist() {
2741 let result =
2742 FileConfig::from_uri("file:/tmp/nonexistent_dir_12345?startingDirectoryMustExist=true");
2743 assert!(
2744 result.is_err(),
2745 "should reject non-existent directory when startingDirectoryMustExist=true"
2746 );
2747 let err = result.unwrap_err().to_string();
2748 assert!(
2749 err.contains("does not exist"),
2750 "error should mention directory does not exist: {err}"
2751 );
2752 }
2753
2754 #[test]
2755 fn test_accepts_existing_directory_when_starting_directory_must_exist() {
2756 let dir = tempfile::tempdir().unwrap();
2757 let dir_path = dir.path().to_str().unwrap();
2758 let result =
2759 FileConfig::from_uri(&format!("file:{dir_path}?startingDirectoryMustExist=true"));
2760 assert!(
2761 result.is_ok(),
2762 "should accept existing directory when startingDirectoryMustExist=true: {:?}",
2763 result.err()
2764 );
2765 }
2766
2767 #[test]
2768 fn test_valid_config_passes() {
2769 let cfg = FileConfig::from_uri("file:/tmp/inbox").unwrap();
2770 assert!(cfg.validate().is_ok());
2771 }
2772
2773 #[test]
2774 fn test_file_exist_strategy_rejects_unknown() {
2775 let result = FileExistStrategy::from_str("BogusValue");
2777 assert!(
2778 result.is_err(),
2779 "unknown FileExistStrategy should return Err"
2780 );
2781 }
2782
2783 #[test]
2784 fn test_path_contains_traversal_detects_parent_dir() {
2785 assert!(path_contains_traversal("../etc/passwd"));
2786 assert!(path_contains_traversal("foo/../bar"));
2787 assert!(path_contains_traversal(".."));
2788 assert!(path_contains_traversal("a/b/../../../c"));
2789 }
2790
2791 #[test]
2792 fn test_path_contains_traversal_accepts_safe_paths() {
2793 assert!(!path_contains_traversal("safe/path"));
2794 assert!(!path_contains_traversal("/absolute/path"));
2795 assert!(!path_contains_traversal("filename.txt"));
2796 assert!(!path_contains_traversal(".hidden"));
2797 assert!(!path_contains_traversal(""));
2798 }
2799
2800 #[tokio::test]
2805 async fn test_stream_detects_file_modified_during_read() {
2806 use futures::StreamExt;
2807
2808 let dir = tempfile::tempdir().unwrap();
2809 let file_path = dir.path().join("mutable.txt");
2810 std::fs::write(&file_path, b"initial content here").unwrap();
2811
2812 let initial_meta = std::fs::metadata(&file_path).unwrap();
2814 let initial_size = initial_meta.len();
2815 let initial_mtime = initial_meta.modified().ok();
2816
2817 let file = tokio::fs::File::open(&file_path).await.unwrap();
2819 let raw_stream = ReaderStream::new(file).map(|res| res.map_err(CamelError::from));
2820
2821 let mut stream = ModificationDetectingStream::new(
2822 raw_stream,
2823 file_path.clone(),
2824 initial_size,
2825 initial_mtime,
2826 );
2827
2828 let chunk = stream.next().await;
2830 assert!(chunk.is_some(), "should produce at least one chunk");
2831 assert!(chunk.as_ref().unwrap().is_ok(), "first chunk should be Ok");
2832
2833 std::fs::write(&file_path, b"modified content - different size!!").unwrap();
2835
2836 let mut got_error = false;
2838 while let Some(result) = stream.next().await {
2839 if result.is_err() {
2840 let err = result.unwrap_err();
2841 let msg = err.to_string();
2842 assert!(
2843 msg.contains("file modified during read"),
2844 "expected modification error, got: {msg}"
2845 );
2846 got_error = true;
2847 break;
2848 }
2849 }
2850
2851 assert!(
2852 got_error,
2853 "stream should detect file was modified during read"
2854 );
2855 }
2856
2857 #[tokio::test]
2858 async fn test_stream_succeeds_when_file_not_modified() {
2859 use futures::StreamExt;
2860
2861 let dir = tempfile::tempdir().unwrap();
2862 let file_path = dir.path().join("stable.txt");
2863 std::fs::write(&file_path, b"stable content").unwrap();
2864
2865 let initial_meta = std::fs::metadata(&file_path).unwrap();
2866 let initial_size = initial_meta.len();
2867 let initial_mtime = initial_meta.modified().ok();
2868
2869 let file = tokio::fs::File::open(&file_path).await.unwrap();
2870 let raw_stream = ReaderStream::new(file).map(|res| res.map_err(CamelError::from));
2871
2872 let mut stream = ModificationDetectingStream::new(
2873 raw_stream,
2874 file_path.clone(),
2875 initial_size,
2876 initial_mtime,
2877 );
2878
2879 let mut all_ok = true;
2881 while let Some(result) = stream.next().await {
2882 if result.is_err() {
2883 all_ok = false;
2884 break;
2885 }
2886 }
2887
2888 assert!(
2889 all_ok,
2890 "stream should complete without errors when file is not modified"
2891 );
2892 }
2893
2894 #[test]
2899 fn test_sort_spec_bare_field() {
2900 let spec: SortSpec = "file:name".parse().unwrap();
2901 assert_eq!(spec.groups.len(), 1);
2902 let g = &spec.groups[0];
2903 assert_eq!(g.field, SortField::Name);
2904 assert!(!g.reverse);
2905 assert!(!g.ignore_case);
2906 }
2907
2908 #[test]
2909 fn test_sort_spec_reverse() {
2910 let spec: SortSpec = "reverse:file:length".parse().unwrap();
2911 assert_eq!(spec.groups.len(), 1);
2912 let g = &spec.groups[0];
2913 assert_eq!(g.field, SortField::Length);
2914 assert!(g.reverse);
2915 assert!(!g.ignore_case);
2916 }
2917
2918 #[test]
2919 fn test_sort_spec_ignore_case() {
2920 let spec: SortSpec = "ignoreCase:file:name".parse().unwrap();
2921 assert_eq!(spec.groups.len(), 1);
2922 let g = &spec.groups[0];
2923 assert_eq!(g.field, SortField::Name);
2924 assert!(!g.reverse);
2925 assert!(g.ignore_case);
2926 }
2927
2928 #[test]
2929 fn test_sort_spec_reverse_ignore_case() {
2930 let spec: SortSpec = "reverse:ignoreCase:file:modified".parse().unwrap();
2931 assert_eq!(spec.groups.len(), 1);
2932 let g = &spec.groups[0];
2933 assert_eq!(g.field, SortField::Modified);
2934 assert!(g.reverse);
2935 assert!(g.ignore_case);
2936 }
2937
2938 #[test]
2939 fn test_sort_spec_wrong_order_rejection() {
2940 let result: Result<SortSpec, _> = "ignoreCase:reverse:file:name".parse();
2941 assert!(result.is_err());
2942 let err = result.unwrap_err();
2943 assert!(
2944 err.contains("reverse must precede"),
2945 "expected 'reverse must precede' error, got: {err}"
2946 );
2947 }
2948
2949 #[test]
2950 fn test_sort_spec_multi_group() {
2951 let spec: SortSpec = "file:name;reverse:file:length".parse().unwrap();
2952 assert_eq!(spec.groups.len(), 2);
2953 assert_eq!(spec.groups[0].field, SortField::Name);
2954 assert!(!spec.groups[0].reverse);
2955 assert_eq!(spec.groups[1].field, SortField::Length);
2956 assert!(spec.groups[1].reverse);
2957 }
2958
2959 #[test]
2960 fn test_sort_spec_unsupported_field() {
2961 let result: Result<SortSpec, _> = "file:unknown".parse();
2962 assert!(result.is_err());
2963 let err = result.unwrap_err();
2964 assert!(
2965 err.contains("unsupported"),
2966 "expected 'unsupported' error, got: {err}"
2967 );
2968 }
2969
2970 #[test]
2971 fn test_sort_spec_empty_string() {
2972 let result: Result<SortSpec, _> = "".parse();
2973 assert!(result.is_err());
2974 let err = result.unwrap_err();
2975 assert!(
2976 err.contains("at least one group"),
2977 "expected 'at least one group' error, got: {err}"
2978 );
2979 }
2980
2981 #[tokio::test]
2982 async fn test_max_depth_limits_recursion() {
2983 let dir = tempfile::tempdir().unwrap();
2984 let base = dir.path();
2985 fs::create_dir_all(base.join("sub/deep")).await.unwrap();
2986 fs::write(base.join("root.txt"), b"r").await.unwrap();
2987 fs::write(base.join("sub/mid.txt"), b"m").await.unwrap();
2988 fs::write(base.join("sub/deep/leaf.txt"), b"l")
2989 .await
2990 .unwrap();
2991
2992 let config = FileConfig::from_uri(&format!(
2993 "file://{}?recursive=true&maxDepth=2",
2994 base.display()
2995 ))
2996 .unwrap();
2997 let filters = CompiledFilters::compile(&config).unwrap();
2998 let mut seen = HashSet::new();
2999 let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3000 .await
3001 .unwrap()
3002 .candidates;
3003 let names: Vec<String> = candidates
3004 .iter()
3005 .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3006 .collect();
3007 assert!(names.contains(&"root.txt".to_string()));
3008 assert!(names.contains(&"mid.txt".to_string()));
3009 assert!(!names.contains(&"leaf.txt".to_string()));
3010 }
3011
3012 #[tokio::test]
3013 async fn test_min_depth_skips_base_files() {
3014 let dir = tempfile::tempdir().unwrap();
3015 let base = dir.path();
3016 fs::create_dir_all(base.join("sub")).await.unwrap();
3017 fs::write(base.join("root.txt"), b"r").await.unwrap();
3018 fs::write(base.join("sub/deep.txt"), b"d").await.unwrap();
3019
3020 let config = FileConfig::from_uri(&format!(
3021 "file://{}?recursive=true&minDepth=2",
3022 base.display()
3023 ))
3024 .unwrap();
3025 let filters = CompiledFilters::compile(&config).unwrap();
3026 let mut seen = HashSet::new();
3027 let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3028 .await
3029 .unwrap()
3030 .candidates;
3031 let names: Vec<String> = candidates
3032 .iter()
3033 .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3034 .collect();
3035 assert!(!names.contains(&"root.txt".to_string()));
3036 assert!(names.contains(&"deep.txt".to_string()));
3037 }
3038
3039 #[tokio::test]
3040 async fn test_include_ext_filter() {
3041 let dir = tempfile::tempdir().unwrap();
3042 let base = dir.path();
3043 fs::write(base.join("data.txt"), b"t").await.unwrap();
3044 fs::write(base.join("data.csv"), b"c").await.unwrap();
3045 fs::write(base.join("data.json"), b"j").await.unwrap();
3046
3047 let config =
3048 FileConfig::from_uri(&format!("file://{}?includeExt=txt,csv", base.display())).unwrap();
3049 let filters = CompiledFilters::compile(&config).unwrap();
3050 let mut seen = HashSet::new();
3051 let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3052 .await
3053 .unwrap()
3054 .candidates;
3055 let names: Vec<String> = candidates
3056 .iter()
3057 .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3058 .collect();
3059 assert_eq!(names.len(), 2);
3060 assert!(names.contains(&"data.txt".to_string()));
3061 assert!(names.contains(&"data.csv".to_string()));
3062 }
3063
3064 #[tokio::test]
3065 async fn test_ant_include_glob() {
3066 let dir = tempfile::tempdir().unwrap();
3067 let base = dir.path();
3068 fs::write(base.join("report.txt"), b"r").await.unwrap();
3069 fs::write(base.join("data.csv"), b"d").await.unwrap();
3070 fs::write(base.join("image.png"), b"i").await.unwrap();
3071
3072 let config =
3073 FileConfig::from_uri(&format!("file://{}?antInclude=*.txt,*.csv", base.display()))
3074 .unwrap();
3075 let filters = CompiledFilters::compile(&config).unwrap();
3076 let mut seen = HashSet::new();
3077 let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3078 .await
3079 .unwrap()
3080 .candidates;
3081 let names: Vec<String> = candidates
3082 .iter()
3083 .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3084 .collect();
3085 assert_eq!(names.len(), 2);
3086 assert!(names.contains(&"report.txt".to_string()));
3087 assert!(names.contains(&"data.csv".to_string()));
3088 }
3089
3090 #[tokio::test]
3091 async fn test_exclude_ext_filter() {
3092 let dir = tempfile::tempdir().unwrap();
3093 let base = dir.path();
3094 fs::write(base.join("keep.txt"), b"k").await.unwrap();
3095 fs::write(base.join("skip.log"), b"s").await.unwrap();
3096
3097 let config =
3098 FileConfig::from_uri(&format!("file://{}?excludeExt=log", base.display())).unwrap();
3099 let filters = CompiledFilters::compile(&config).unwrap();
3100 let mut seen = HashSet::new();
3101 let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3102 .await
3103 .unwrap()
3104 .candidates;
3105 let names: Vec<String> = candidates
3106 .iter()
3107 .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3108 .collect();
3109 assert_eq!(names.len(), 1);
3110 assert!(names.contains(&"keep.txt".to_string()));
3111 }
3112
3113 #[tokio::test]
3114 async fn test_ant_exclude_glob() {
3115 let dir = tempfile::tempdir().unwrap();
3116 let base = dir.path();
3117 fs::write(base.join("report.txt"), b"r").await.unwrap();
3118 fs::write(base.join("temp.tmp"), b"t").await.unwrap();
3119
3120 let config =
3121 FileConfig::from_uri(&format!("file://{}?antExclude=*.tmp", base.display())).unwrap();
3122 let filters = CompiledFilters::compile(&config).unwrap();
3123 let mut seen = HashSet::new();
3124 let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3125 .await
3126 .unwrap()
3127 .candidates;
3128 let names: Vec<String> = candidates
3129 .iter()
3130 .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3131 .collect();
3132 assert_eq!(names.len(), 1);
3133 assert!(names.contains(&"report.txt".to_string()));
3134 }
3135
3136 #[tokio::test]
3137 async fn test_done_file_consumer_skips_without_marker() {
3138 let dir = tempfile::tempdir().unwrap();
3139 let base = dir.path();
3140 fs::write(base.join("ready.txt"), b"ready").await.unwrap();
3141 fs::write(base.join("ready.txt.done"), b"").await.unwrap();
3142 fs::write(base.join("pending.txt"), b"pending")
3143 .await
3144 .unwrap();
3145
3146 let config = FileConfig::from_uri(&format!(
3147 "file://{}?doneFileName=${{file:name}}.done",
3148 base.display()
3149 ))
3150 .unwrap();
3151 let filters = CompiledFilters::compile(&config).unwrap();
3152 let mut seen = HashSet::new();
3153 let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3154 .await
3155 .unwrap()
3156 .candidates;
3157 let names: Vec<String> = candidates
3158 .iter()
3159 .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3160 .collect();
3161 assert!(names.contains(&"ready.txt".to_string()));
3162 assert!(!names.contains(&"pending.txt".to_string()));
3163 assert!(!names.contains(&"ready.txt.done".to_string()));
3164 }
3165
3166 #[tokio::test]
3167 async fn test_done_file_static_pattern() {
3168 let dir = tempfile::tempdir().unwrap();
3169 let base = dir.path();
3170 fs::write(base.join("data.txt"), b"d").await.unwrap();
3171 fs::write(base.join("ready"), b"").await.unwrap();
3172
3173 let config =
3174 FileConfig::from_uri(&format!("file://{}?doneFileName=ready", base.display())).unwrap();
3175 let filters = CompiledFilters::compile(&config).unwrap();
3176 let mut seen = HashSet::new();
3177 let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3178 .await
3179 .unwrap()
3180 .candidates;
3181 assert_eq!(candidates.len(), 1);
3182 assert_eq!(
3183 candidates[0].path.file_name().unwrap().to_str().unwrap(),
3184 "data.txt"
3185 );
3186 }
3187
3188 #[tokio::test]
3193 async fn test_sort_by_name() {
3194 let dir = tempfile::tempdir().unwrap();
3195 let base = dir.path();
3196 fs::write(base.join("c.txt"), b"3").await.unwrap();
3197 fs::write(base.join("a.txt"), b"1").await.unwrap();
3198 fs::write(base.join("b.txt"), b"2").await.unwrap();
3199
3200 let config =
3201 FileConfig::from_uri(&format!("file://{}?sortBy=file:name", base.display())).unwrap();
3202 let filters = CompiledFilters::compile(&config).unwrap();
3203 let mut seen = HashSet::new();
3204 let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3205 .await
3206 .unwrap()
3207 .candidates;
3208 apply_sort_and_limit(&mut candidates, &config);
3209 let names: Vec<&str> = candidates
3210 .iter()
3211 .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3212 .collect();
3213 assert_eq!(names, vec!["a.txt", "b.txt", "c.txt"]);
3214 }
3215
3216 #[tokio::test]
3217 async fn test_sort_by_length() {
3218 let dir = tempfile::tempdir().unwrap();
3219 let base = dir.path();
3220 fs::write(base.join("big.txt"), b"xxx").await.unwrap();
3221 fs::write(base.join("small.txt"), b"x").await.unwrap();
3222
3223 let config =
3224 FileConfig::from_uri(&format!("file://{}?sortBy=file:length", base.display())).unwrap();
3225 let filters = CompiledFilters::compile(&config).unwrap();
3226 let mut seen = HashSet::new();
3227 let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3228 .await
3229 .unwrap()
3230 .candidates;
3231 apply_sort_and_limit(&mut candidates, &config);
3232 let names: Vec<&str> = candidates
3233 .iter()
3234 .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3235 .collect();
3236 assert_eq!(names, vec!["small.txt", "big.txt"]);
3237 }
3238
3239 #[tokio::test]
3240 async fn test_sort_by_reverse_name() {
3241 let dir = tempfile::tempdir().unwrap();
3242 let base = dir.path();
3243 fs::write(base.join("a.txt"), b"1").await.unwrap();
3244 fs::write(base.join("b.txt"), b"2").await.unwrap();
3245 fs::write(base.join("c.txt"), b"3").await.unwrap();
3246
3247 let config = FileConfig::from_uri(&format!(
3248 "file://{}?sortBy=reverse:file:name",
3249 base.display()
3250 ))
3251 .unwrap();
3252 let filters = CompiledFilters::compile(&config).unwrap();
3253 let mut seen = HashSet::new();
3254 let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3255 .await
3256 .unwrap()
3257 .candidates;
3258 apply_sort_and_limit(&mut candidates, &config);
3259 let names: Vec<&str> = candidates
3260 .iter()
3261 .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3262 .collect();
3263 assert_eq!(names, vec!["c.txt", "b.txt", "a.txt"]);
3264 }
3265
3266 #[tokio::test]
3267 async fn test_shuffle_deterministic() {
3268 let dir = tempfile::tempdir().unwrap();
3269 let base = dir.path();
3270 fs::write(base.join("a.txt"), b"1").await.unwrap();
3271 fs::write(base.join("b.txt"), b"2").await.unwrap();
3272 fs::write(base.join("c.txt"), b"3").await.unwrap();
3273 fs::write(base.join("d.txt"), b"4").await.unwrap();
3274
3275 let config =
3276 FileConfig::from_uri(&format!("file://{}?shuffle=true", base.display())).unwrap();
3277 let filters = CompiledFilters::compile(&config).unwrap();
3278 let mut seen = HashSet::new();
3279 let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3280 .await
3281 .unwrap()
3282 .candidates;
3283 crate::poll_logic::apply_sort_and_limit(&mut candidates, &config);
3284
3285 let mut candidates2 =
3286 crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3287 .await
3288 .unwrap()
3289 .candidates;
3290 crate::poll_logic::apply_sort_and_limit(&mut candidates2, &config);
3291
3292 let names1: Vec<&str> = candidates
3293 .iter()
3294 .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3295 .collect();
3296 let names2: Vec<&str> = candidates2
3297 .iter()
3298 .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3299 .collect();
3300 assert_eq!(names1, names2);
3301 assert_ne!(names1, vec!["a.txt", "b.txt", "c.txt", "d.txt"]);
3302 }
3303
3304 #[tokio::test]
3305 async fn test_non_eager_limit() {
3306 let dir = tempfile::tempdir().unwrap();
3307 let base = dir.path();
3308 fs::write(base.join("a.txt"), b"1").await.unwrap();
3309 fs::write(base.join("b.txt"), b"2").await.unwrap();
3310 fs::write(base.join("c.txt"), b"3").await.unwrap();
3311
3312 let config = FileConfig::from_uri(&format!(
3313 "file://{}?maxMessagesPerPoll=2&eagerMaxMessagesPerPoll=false",
3314 base.display()
3315 ))
3316 .unwrap();
3317 let filters = CompiledFilters::compile(&config).unwrap();
3318 let mut seen = HashSet::new();
3319 let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3320 .await
3321 .unwrap()
3322 .candidates;
3323 assert_eq!(candidates.len(), 3);
3324 crate::poll_logic::apply_sort_and_limit(&mut candidates, &config);
3325 assert_eq!(candidates.len(), 2);
3326 }
3327
3328 #[tokio::test]
3329 async fn test_poll_one_file_sets_all_headers() {
3330 let dir = tempfile::tempdir().unwrap();
3331 let base = dir.path();
3332 fs::write(base.join("test.txt"), b"hello world")
3333 .await
3334 .unwrap();
3335
3336 let config = FileConfig::from_uri(&format!("file:{}?noop=true", base.display())).unwrap();
3337
3338 let file_path = base.join("test.txt");
3339 let include_re = None;
3340 let exclude_re = None;
3341 let mut seen = std::collections::HashSet::new();
3342 let in_process_locks = std::sync::Arc::new(DashMap::new());
3343 let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
3344
3345 let result = poll_one_file(
3346 &config,
3347 file_path.clone(),
3348 base,
3349 &include_re,
3350 &exclude_re,
3351 &mut seen,
3352 &in_process_locks,
3353 &idempotent_repo,
3354 )
3355 .await
3356 .unwrap();
3357
3358 let exchange = result.expect("poll_one_file should return an exchange");
3359
3360 let camel_file_name = exchange
3361 .input
3362 .header("CamelFileName")
3363 .and_then(|v| v.as_str().map(String::from))
3364 .expect("CamelFileName header");
3365 let camel_file_name_only = exchange
3366 .input
3367 .header("CamelFileNameOnly")
3368 .and_then(|v| v.as_str().map(String::from))
3369 .expect("CamelFileNameOnly header");
3370 let camel_file_absolute_path = exchange
3371 .input
3372 .header("CamelFileAbsolutePath")
3373 .and_then(|v| v.as_str().map(String::from))
3374 .expect("CamelFileAbsolutePath header");
3375 exchange
3376 .input
3377 .header("CamelFileLength")
3378 .and_then(|v| v.as_u64())
3379 .expect("CamelFileLength header");
3380 exchange
3381 .input
3382 .header("CamelFileLastModified")
3383 .and_then(|v| v.as_u64())
3384 .expect("CamelFileLastModified header");
3385 let camel_file_path = exchange
3386 .input
3387 .header("CamelFilePath")
3388 .and_then(|v| v.as_str().map(String::from))
3389 .expect("CamelFilePath header");
3390 let camel_file_parent = exchange
3391 .input
3392 .header("CamelFileParent")
3393 .and_then(|v| v.as_str().map(String::from))
3394 .expect("CamelFileParent header");
3395 let camel_file_canonical_path = exchange
3396 .input
3397 .header("CamelFileCanonicalPath")
3398 .and_then(|v| v.as_str().map(String::from))
3399 .expect("CamelFileCanonicalPath header");
3400 let camel_file_relative_path = exchange
3401 .input
3402 .header("CamelFileRelativePath")
3403 .and_then(|v| v.as_str().map(String::from))
3404 .expect("CamelFileRelativePath header");
3405
3406 assert_eq!(camel_file_name, "test.txt");
3407 assert_eq!(camel_file_name_only, "test.txt");
3408 assert_eq!(camel_file_relative_path, camel_file_name);
3409 assert_eq!(camel_file_path, base.to_string_lossy());
3410 assert_eq!(camel_file_parent, base.to_string_lossy());
3411 assert!(camel_file_absolute_path.ends_with("test.txt"));
3412 assert!(camel_file_canonical_path.ends_with("test.txt"));
3413 }
3414
3415 #[test]
3416 fn test_try_rename_requires_temp_prefix() {
3417 let result = FileConfig::from_uri("file:///tmp?fileExist=TryRename");
3418 assert!(result.is_err());
3419 assert!(
3420 result
3421 .unwrap_err()
3422 .to_string()
3423 .contains("TryRename requires tempPrefix")
3424 );
3425 }
3426
3427 #[test]
3428 fn test_try_rename_with_temp_prefix_ok() {
3429 let result = FileConfig::from_uri("file:///tmp?fileExist=TryRename&tempPrefix=tmp_");
3430 assert!(result.is_ok());
3431 assert_eq!(result.unwrap().file_exist, FileExistStrategy::TryRename);
3432 }
3433
3434 #[tokio::test]
3435 async fn test_try_rename_producer_writes_file() {
3436 use tower::ServiceExt;
3437
3438 let dir = tempfile::tempdir().unwrap();
3439 let base = dir.path();
3440
3441 let component = FileComponent::new();
3442 let ctx = NoOpComponentContext;
3443 let endpoint = component
3444 .create_endpoint(
3445 &format!(
3446 "file://{}?fileExist=TryRename&tempPrefix=tmp_",
3447 base.display()
3448 ),
3449 &ctx,
3450 )
3451 .unwrap();
3452 let ctx = test_producer_ctx();
3453 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3454
3455 let mut exchange = Exchange::new(Message::new("hello"));
3456 exchange.input.set_header(
3457 "CamelFileName",
3458 serde_json::Value::String("output.txt".to_string()),
3459 );
3460
3461 producer.oneshot(exchange).await.unwrap();
3462
3463 let target = base.join("output.txt");
3464 assert!(target.exists(), "output file should exist");
3465 let content = std::fs::read_to_string(&target).unwrap();
3466 assert_eq!(content, "hello");
3467
3468 let tmp_files: Vec<_> = std::fs::read_dir(base)
3469 .unwrap()
3470 .filter_map(|e| e.ok())
3471 .filter(|e| e.file_name().to_string_lossy().starts_with("tmp_"))
3472 .collect();
3473 assert!(tmp_files.is_empty(), "no temp files should remain");
3474 }
3475
3476 #[tokio::test]
3481 async fn test_pipeline_sort_then_limit() {
3482 let dir = tempfile::tempdir().unwrap();
3483 let base = dir.path();
3484 fs::write(base.join("c.txt"), b"ccc").await.unwrap();
3485 fs::write(base.join("a.txt"), b"a").await.unwrap();
3486 fs::write(base.join("b.txt"), b"bb").await.unwrap();
3487
3488 let config = FileConfig::from_uri(&format!(
3489 "file://{}?sortBy=file:length&maxMessagesPerPoll=2&eagerMaxMessagesPerPoll=false",
3490 base.display()
3491 ))
3492 .unwrap();
3493 let filters = CompiledFilters::compile(&config).unwrap();
3494 let mut seen = HashSet::new();
3495 let mut candidates = scan_candidates(&config, &filters, base, &mut seen)
3496 .await
3497 .unwrap()
3498 .candidates;
3499 apply_sort_and_limit(&mut candidates, &config);
3500 assert_eq!(candidates.len(), 2);
3501 assert_eq!(
3502 candidates[0].path.file_name().unwrap().to_str().unwrap(),
3503 "a.txt"
3504 );
3505 assert_eq!(
3506 candidates[1].path.file_name().unwrap().to_str().unwrap(),
3507 "b.txt"
3508 );
3509 }
3510
3511 #[tokio::test]
3512 async fn test_sort_reverse_ignore_case() {
3513 let dir = tempfile::tempdir().unwrap();
3514 let base = dir.path();
3515 fs::write(base.join("Alpha.txt"), b"a").await.unwrap();
3516 fs::write(base.join("beta.txt"), b"b").await.unwrap();
3517
3518 let config = FileConfig::from_uri(&format!(
3519 "file://{}?sortBy=reverse:ignoreCase:file:name",
3520 base.display()
3521 ))
3522 .unwrap();
3523 let filters = CompiledFilters::compile(&config).unwrap();
3524 let mut seen = HashSet::new();
3525 let mut candidates = scan_candidates(&config, &filters, base, &mut seen)
3526 .await
3527 .unwrap()
3528 .candidates;
3529 apply_sort_and_limit(&mut candidates, &config);
3530 assert_eq!(
3531 candidates[0].path.file_name().unwrap().to_str().unwrap(),
3532 "beta.txt"
3533 );
3534 }
3535
3536 #[tokio::test]
3537 async fn test_done_file_noop_no_deletion() {
3538 let dir = tempfile::tempdir().unwrap();
3539 let base = dir.path();
3540 fs::write(base.join("data.txt"), b"d").await.unwrap();
3541 fs::write(base.join("done"), b"").await.unwrap();
3542
3543 let config = FileConfig::from_uri(&format!(
3544 "file://{}?doneFileName=done&noop=true",
3545 base.display()
3546 ))
3547 .unwrap();
3548 let filters = CompiledFilters::compile(&config).unwrap();
3549 let mut seen = HashSet::new();
3550 let candidates = scan_candidates(&config, &filters, base, &mut seen)
3551 .await
3552 .unwrap()
3553 .candidates;
3554 assert_eq!(candidates.len(), 1);
3555 assert!(base.join("done").exists());
3556 }
3557
3558 #[tokio::test]
3559 async fn test_static_done_file_deleted_after_all_processed() {
3560 let dir = tempfile::tempdir().unwrap();
3561 let base = dir.path();
3562 fs::write(base.join("data.txt"), b"d").await.unwrap();
3563 fs::write(base.join("ready"), b"").await.unwrap();
3564
3565 let config = FileConfig::from_uri(&format!(
3566 "file://{}?doneFileName=ready&delete=true",
3567 base.display()
3568 ))
3569 .unwrap();
3570 let filters = CompiledFilters::compile(&config).unwrap();
3571 let mut seen = HashSet::new();
3572 let in_process_locks = std::sync::Arc::new(DashMap::new());
3573 let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
3574 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
3575 let token = CancellationToken::new();
3576 let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
3577
3578 poll_directory(
3579 &config,
3580 &ctx,
3581 &filters,
3582 &mut seen,
3583 &in_process_locks,
3584 &idempotent_repo,
3585 )
3586 .await
3587 .unwrap();
3588
3589 assert!(rx.try_recv().is_ok());
3590 assert!(!base.join("ready").exists());
3591 }
3592
3593 #[tokio::test]
3594 async fn test_static_done_file_not_deleted_when_limited() {
3595 let dir = tempfile::tempdir().unwrap();
3596 let base = dir.path();
3597 fs::write(base.join("a.txt"), b"a").await.unwrap();
3598 fs::write(base.join("b.txt"), b"b").await.unwrap();
3599 fs::write(base.join("ready"), b"").await.unwrap();
3600
3601 let config = FileConfig::from_uri(&format!(
3602 "file://{}?doneFileName=ready&delete=true&maxMessagesPerPoll=1&eagerMaxMessagesPerPoll=true",
3603 base.display()
3604 ))
3605 .unwrap();
3606 let filters = CompiledFilters::compile(&config).unwrap();
3607 let mut seen = HashSet::new();
3608 let in_process_locks = std::sync::Arc::new(DashMap::new());
3609 let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
3610 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
3611 let token = CancellationToken::new();
3612 let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
3613
3614 poll_directory(
3615 &config,
3616 &ctx,
3617 &filters,
3618 &mut seen,
3619 &in_process_locks,
3620 &idempotent_repo,
3621 )
3622 .await
3623 .unwrap();
3624
3625 let _ = rx.try_recv();
3626 assert!(base.join("ready").exists());
3627 }
3628
3629 #[tokio::test]
3630 async fn test_dynamic_done_file_deleted_per_file() {
3631 let dir = tempfile::tempdir().unwrap();
3632 let base = dir.path();
3633 fs::write(base.join("data.txt"), b"d").await.unwrap();
3634 fs::write(base.join("data.txt.done"), b"").await.unwrap();
3635
3636 let config = FileConfig::from_uri(&format!(
3637 "file://{}?doneFileName=${{file:name}}.done&delete=true",
3638 base.display()
3639 ))
3640 .unwrap();
3641 let filters = CompiledFilters::compile(&config).unwrap();
3642 let mut seen = HashSet::new();
3643 let in_process_locks = std::sync::Arc::new(DashMap::new());
3644 let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
3645 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
3646 let token = CancellationToken::new();
3647 let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
3648
3649 poll_directory(
3650 &config,
3651 &ctx,
3652 &filters,
3653 &mut seen,
3654 &in_process_locks,
3655 &idempotent_repo,
3656 )
3657 .await
3658 .unwrap();
3659
3660 assert!(rx.try_recv().is_ok());
3661 assert!(!base.join("data.txt.done").exists());
3662 }
3663}