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