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