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