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