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