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() {
1210 let canonical_base = base_dir.canonicalize().map_err(|e| {
1211 CamelError::ProcessorError(format!("Cannot canonicalize base directory: {}", e))
1212 })?;
1213
1214 let rel = target_path.strip_prefix(base_dir).map_err(|_| {
1216 CamelError::ProcessorError(format!(
1217 "Path '{}' is not under base '{}'",
1218 target_path.display(),
1219 base_dir.display()
1220 ))
1221 })?;
1222 if path_contains_traversal(&rel.to_string_lossy()) {
1223 return Err(CamelError::ProcessorError(format!(
1224 "Path '{}' contains directory traversal",
1225 target_path.display()
1226 )));
1227 }
1228
1229 let mut cumulative = base_dir.to_path_buf();
1235 for component in rel.components() {
1236 cumulative.push(component);
1237 if std::fs::symlink_metadata(&cumulative).is_ok_and(|m| m.file_type().is_symlink()) {
1238 return Err(CamelError::ProcessorError(format!(
1239 "Path '{}' traverses symlinked component '{}' below base '{}'",
1240 target_path.display(),
1241 cumulative.display(),
1242 base_dir.display()
1243 )));
1244 }
1245 }
1246
1247 let mut ancestor = target_path;
1253 while std::fs::symlink_metadata(ancestor).is_err() {
1254 match ancestor.parent() {
1255 Some(parent) => ancestor = parent,
1256 None => {
1257 ancestor = base_dir;
1258 break;
1259 }
1260 }
1261 }
1262 let canonical_ancestor = ancestor.canonicalize().map_err(|e| {
1263 CamelError::ProcessorError(format!(
1264 "Cannot canonicalize existing ancestor '{}': {}",
1265 ancestor.display(),
1266 e
1267 ))
1268 })?;
1269 if !canonical_ancestor.starts_with(&canonical_base) {
1270 return Err(CamelError::ProcessorError(format!(
1271 "Path '{}' is outside base directory '{}'",
1272 canonical_ancestor.display(),
1273 canonical_base.display()
1274 )));
1275 }
1276 } else {
1277 let rel = target_path.strip_prefix(base_dir).map_err(|_| {
1281 CamelError::ProcessorError(format!(
1282 "Path '{}' is not under base '{}'",
1283 target_path.display(),
1284 base_dir.display()
1285 ))
1286 })?;
1287 let rel_str = rel.to_string_lossy();
1288 if path_contains_traversal(&rel_str) {
1289 return Err(CamelError::ProcessorError(format!(
1290 "Path '{}' contains directory traversal",
1291 target_path.display()
1292 )));
1293 }
1294 }
1295
1296 Ok(())
1297}
1298
1299#[derive(Clone)]
1304struct FileProducer {
1305 config: FileConfig,
1306}
1307
1308impl FileProducer {
1309 async fn resolve_filename(
1310 exchange: &Exchange,
1311 config: &FileConfig,
1312 ) -> Result<String, CamelError> {
1313 let raw = if let Some(name) = exchange
1314 .input
1315 .header("CamelFileName")
1316 .and_then(|v| v.as_str())
1317 {
1318 Some(name.to_string())
1319 } else {
1320 config.file_name.clone()
1321 };
1322
1323 match raw {
1324 Some(name) if name.contains("${") => {
1325 let lang = SimpleLanguage::new();
1326 let expr = lang.create_expression(&name).map_err(|e| {
1327 CamelError::ProcessorError(format!(
1328 "cannot parse fileName expression '{}': {e}",
1329 name
1330 ))
1331 })?;
1332 let val = expr.evaluate(exchange).await.map_err(|e| {
1333 CamelError::ProcessorError(format!(
1334 "cannot evaluate fileName expression '{}': {e}",
1335 name
1336 ))
1337 })?;
1338 match val {
1339 serde_json::Value::String(s) => Ok(s),
1340 other => Ok(other.to_string()),
1341 }
1342 }
1343 Some(name) => Ok(name),
1344 None => Err(CamelError::ProcessorError(
1345 "No filename specified: set CamelFileName header or fileName option".to_string(),
1346 )),
1347 }
1348 }
1349}
1350
1351impl Service<Exchange> for FileProducer {
1352 type Response = Exchange;
1353 type Error = CamelError;
1354 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
1355
1356 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
1357 Poll::Ready(Ok(()))
1358 }
1359
1360 fn call(&mut self, mut exchange: Exchange) -> Self::Future {
1361 let config = self.config.clone();
1362
1363 Box::pin(async move {
1364 let file_name = FileProducer::resolve_filename(&exchange, &config).await?;
1365 let body = exchange.input.body.clone();
1366
1367 validate_relative_filename(&file_name, "fileName")?;
1371
1372 let dir_path = std::path::Path::new(&config.directory);
1373 let target_path = dir_path.join(&file_name);
1374
1375 validate_path_is_within_base(dir_path, &target_path)?;
1377
1378 if config.auto_create
1380 && let Some(parent) = target_path.parent()
1381 {
1382 tokio::time::timeout(config.write_timeout, fs::create_dir_all(parent))
1383 .await
1384 .map_err(|_| CamelError::ProcessorError("Timeout creating directories".into()))?
1385 .map_err(CamelError::from)?;
1386 }
1387
1388 validate_path_is_within_base(dir_path, &target_path)?;
1394
1395 match config.file_exist {
1397 FileExistStrategy::Fail => {
1398 let mut file = tokio::time::timeout(
1399 config.write_timeout,
1400 open_options_no_follow()
1401 .write(true)
1402 .create_new(true)
1403 .open(&target_path),
1404 )
1405 .await
1406 .map_err(|_| {
1407 CamelError::ProcessorError("Timeout opening file with create_new".into())
1408 })?
1409 .map_err(CamelError::from)?;
1410
1411 write_body_with_charset(
1412 body,
1413 &config.charset,
1414 &mut file,
1415 config.write_timeout,
1416 config.max_write_bytes,
1417 )
1418 .await?;
1419 file.flush().await.map_err(CamelError::from)?;
1420 }
1421 FileExistStrategy::Ignore if target_path.exists() => return Ok(exchange),
1422 FileExistStrategy::Append => {
1423 let mut file = tokio::time::timeout(
1427 config.write_timeout,
1428 open_options_no_follow()
1429 .append(true)
1430 .create(true)
1431 .open(&target_path),
1432 )
1433 .await
1434 .map_err(|_| {
1435 CamelError::ProcessorError("Timeout opening file for append".into())
1436 })?
1437 .map_err(CamelError::from)?;
1438
1439 write_body_with_charset(
1440 body,
1441 &config.charset,
1442 &mut file,
1443 config.write_timeout,
1444 config.max_write_bytes,
1445 )
1446 .await?;
1447
1448 file.flush().await.map_err(CamelError::from)?;
1449 }
1450 FileExistStrategy::TryRename => {
1451 let prefix = config.temp_prefix.as_deref().ok_or_else(|| {
1454 CamelError::Config("fileExist=TryRename requires tempPrefix".into())
1455 })?;
1456 crate::atomic_write::atomic_write(
1457 &target_path,
1458 body,
1459 Some(prefix),
1460 config.durable,
1461 config.write_timeout,
1462 &config.charset,
1463 config.max_write_bytes,
1464 )
1465 .await?;
1466 }
1467 _ => {
1468 crate::atomic_write::atomic_write(
1473 &target_path,
1474 body,
1475 config.temp_prefix.as_deref(),
1476 config.durable,
1477 config.write_timeout,
1478 &config.charset,
1479 config.max_write_bytes,
1480 )
1481 .await?;
1482 }
1483 }
1484
1485 if let Some(done_pattern) = &config.done_file_name {
1486 let done_name = done_pattern.replace("${file:name}", &file_name);
1487 validate_relative_filename(&done_name, "doneFileName")?;
1492 let done_path = dir_path.join(&done_name);
1493 validate_path_is_within_base(dir_path, &done_path)?;
1494 tokio::time::timeout(
1495 config.write_timeout,
1496 open_options_no_follow()
1497 .write(true)
1498 .create(true)
1499 .truncate(true)
1500 .open(&done_path),
1501 )
1502 .await
1503 .map_err(|_| CamelError::ProcessorError("Timeout creating done file".into()))?
1504 .map_err(CamelError::from)?;
1505 }
1506
1507 let abs_path = target_path
1509 .canonicalize()
1510 .unwrap_or_else(|_| target_path.clone())
1511 .to_string_lossy()
1512 .to_string();
1513 exchange
1514 .input
1515 .set_header("CamelFileNameProduced", serde_json::Value::String(abs_path));
1516
1517 debug!(
1518 file = %target_path.display(),
1519 correlation_id = %exchange.correlation_id(),
1520 "File written"
1521 );
1522
1523 Ok(exchange)
1524 })
1525 }
1526}
1527
1528pub(crate) async fn write_body_with_charset(
1529 body: Body,
1530 charset: &Option<String>,
1531 file: &mut fs::File,
1532 timeout: Duration,
1533 max_bytes: u64,
1534) -> Result<(), CamelError> {
1535 match body {
1536 Body::Text(text) | Body::Xml(text) => {
1537 let bytes = encode_text_by_charset(&text, charset)?;
1538 if max_bytes > 0 && bytes.len() as u64 > max_bytes {
1539 return Err(CamelError::ProcessorError(format!(
1540 "body of {} bytes exceeds maxWriteBytes limit of {max_bytes}",
1541 bytes.len()
1542 )));
1543 }
1544 tokio::time::timeout(timeout, file.write_all(&bytes))
1545 .await
1546 .map_err(|_| CamelError::ProcessorError("Timeout writing file".into()))?
1547 .map_err(CamelError::from)?;
1548 Ok(())
1549 }
1550 other => {
1551 let mut reader = other.into_async_read()?;
1552 let mut limited = tokio::io::AsyncReadExt::take(
1556 &mut reader,
1557 if max_bytes > 0 {
1558 max_bytes + 1
1559 } else {
1560 u64::MAX
1561 },
1562 );
1563 let copied = tokio::time::timeout(timeout, io::copy(&mut limited, file))
1564 .await
1565 .map_err(|_| CamelError::ProcessorError("Timeout writing to file".into()))?
1566 .map_err(|e| CamelError::ProcessorError(e.to_string()))?;
1567 if max_bytes > 0 && copied > max_bytes {
1568 return Err(CamelError::ProcessorError(format!(
1569 "streamed body exceeds maxWriteBytes limit of {max_bytes} bytes"
1570 )));
1571 }
1572 Ok(())
1573 }
1574 }
1575}
1576
1577fn encode_text_by_charset(text: &str, charset: &Option<String>) -> Result<Vec<u8>, CamelError> {
1578 let Some(charset) = charset.as_ref() else {
1579 return Ok(text.as_bytes().to_vec());
1580 };
1581
1582 if charset.eq_ignore_ascii_case("utf-8") {
1583 return Ok(text.as_bytes().to_vec());
1584 }
1585
1586 if charset.eq_ignore_ascii_case("iso-8859-1") {
1587 let mut out = Vec::with_capacity(text.len());
1588 for ch in text.chars() {
1589 let code = ch as u32;
1590 if code > 0xFF {
1591 return Err(CamelError::ProcessorError(format!(
1592 "character '{ch}' cannot be encoded as ISO-8859-1"
1593 )));
1594 }
1595 out.push(code as u8);
1596 }
1597 return Ok(out);
1598 }
1599
1600 Err(CamelError::Config(format!(
1601 "unsupported charset '{charset}', supported: UTF-8, ISO-8859-1"
1602 )))
1603}
1604
1605#[cfg(test)]
1606mod tests {
1607 use camel_component_api::test_support::PanicRuntimeObservability;
1608 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
1609 std::sync::Arc::new(PanicRuntimeObservability)
1610 }
1611
1612 use super::*;
1613 use crate::poll_logic::{
1614 ModificationDetectingStream, apply_sort_and_limit, list_files, poll_one_file,
1615 scan_candidates,
1616 };
1617 use bytes::Bytes;
1618 use camel_component_api::{Message, NoOpComponentContext, StreamBody, StreamMetadata};
1619 use futures::StreamExt;
1620 use std::time::Duration;
1621 use tokio_util::io::ReaderStream;
1622 use tokio_util::sync::CancellationToken;
1623
1624 fn test_producer_ctx() -> ProducerContext {
1625 ProducerContext::new()
1626 }
1627
1628 #[test]
1629 fn test_file_config_defaults() {
1630 let config = FileConfig::from_uri("file:/tmp/inbox").unwrap();
1631 assert_eq!(config.directory, "/tmp/inbox");
1632 assert_eq!(config.delay, Duration::from_millis(500));
1633 assert_eq!(config.initial_delay, Duration::from_millis(1000));
1634 assert!(!config.noop);
1635 assert!(!config.delete);
1636 assert_eq!(config.move_to, Some(".camel".to_string()));
1637 assert!(config.file_name.is_none());
1638 assert!(config.include.is_none());
1639 assert!(config.exclude.is_none());
1640 assert!(!config.recursive);
1641 assert_eq!(config.file_exist, FileExistStrategy::Override);
1642 assert!(config.temp_prefix.is_none());
1643 assert!(config.auto_create);
1644 assert_eq!(config.read_timeout, Duration::from_secs(30));
1646 assert_eq!(config.write_timeout, Duration::from_secs(30));
1647 }
1648
1649 #[test]
1650 fn file_config_durable_defaults_to_false() {
1651 let config = FileConfig::from_uri("file:/tmp/inbox").unwrap();
1652 assert!(!config.durable, "durable must default to false");
1653 }
1654
1655 #[test]
1656 fn file_config_durable_true_parsed_from_uri() {
1657 let config = FileConfig::from_uri("file:/tmp/inbox?durable=true").unwrap();
1658 assert!(config.durable, "durable=true must parse from URI");
1659 }
1660
1661 #[test]
1662 fn file_config_durable_rejects_invalid_value() {
1663 let result = FileConfig::from_uri("file:/tmp/inbox?durable=maybe");
1664 assert!(result.is_err(), "invalid durable value must be rejected");
1665 }
1666
1667 #[test]
1668 fn test_file_config_consumer_options() {
1669 let config = FileConfig::from_uri(
1670 "file:/data/input?delay=1000&initialDelay=2000&noop=true&recursive=true&include=.*\\.csv"
1671 ).unwrap();
1672 assert_eq!(config.directory, "/data/input");
1673 assert_eq!(config.delay, Duration::from_millis(1000));
1674 assert_eq!(config.initial_delay, Duration::from_millis(2000));
1675 assert!(config.noop);
1676 assert!(config.recursive);
1677 assert_eq!(config.include, Some(".*\\.csv".to_string()));
1678 }
1679
1680 #[test]
1681 fn test_file_config_producer_options() {
1682 let config = FileConfig::from_uri(
1683 "file:/data/output?fileExist=Append&tempPrefix=.tmp&autoCreate=false&fileName=out.txt",
1684 )
1685 .unwrap();
1686 assert_eq!(config.file_exist, FileExistStrategy::Append);
1687 assert_eq!(config.temp_prefix, Some(".tmp".to_string()));
1688 assert!(!config.auto_create);
1689 assert_eq!(config.file_name, Some("out.txt".to_string()));
1690 }
1691
1692 #[test]
1693 fn test_file_config_delete_mode() {
1694 let config = FileConfig::from_uri("file:/tmp/inbox?delete=true").unwrap();
1695 assert!(config.delete);
1696 assert!(config.move_to.is_none());
1697 }
1698
1699 #[test]
1700 fn test_file_config_noop_mode() {
1701 let config = FileConfig::from_uri("file:/tmp/inbox?noop=true").unwrap();
1702 assert!(config.noop);
1703 assert!(config.move_to.is_none());
1704 }
1705
1706 #[test]
1707 fn test_file_config_wrong_scheme() {
1708 let result = FileConfig::from_uri("timer:tick");
1709 assert!(result.is_err());
1710 }
1711
1712 #[test]
1713 fn test_min_depth_greater_than_max_depth_rejected() {
1714 let result = FileConfig::from_uri("file:///tmp?minDepth=5&maxDepth=2");
1715 assert!(result.is_err());
1716 assert!(
1717 result
1718 .unwrap_err()
1719 .to_string()
1720 .contains("minDepth cannot be greater")
1721 );
1722 }
1723
1724 #[test]
1725 fn test_file_component_scheme() {
1726 let component = FileComponent::new();
1727 assert_eq!(component.scheme(), "file");
1728 }
1729
1730 #[test]
1731 fn test_file_component_creates_endpoint() {
1732 let component = FileComponent::new();
1733 let ctx = NoOpComponentContext;
1734 let endpoint = component.create_endpoint("file:/tmp/test", &ctx);
1735 assert!(endpoint.is_ok());
1736 }
1737
1738 #[tokio::test]
1743 async fn test_file_consumer_reads_files() {
1744 let dir = tempfile::tempdir().unwrap();
1745 let dir_path = dir.path().to_str().unwrap();
1746
1747 std::fs::write(dir.path().join("test1.txt"), "hello").unwrap();
1748 std::fs::write(dir.path().join("test2.txt"), "world").unwrap();
1749
1750 let component = FileComponent::new();
1751 let ctx = NoOpComponentContext;
1752 let endpoint = component
1753 .create_endpoint(
1754 &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100"),
1755 &ctx,
1756 )
1757 .unwrap();
1758 let mut consumer = endpoint.create_consumer(rt()).unwrap();
1759
1760 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1761 let token = CancellationToken::new();
1762 let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
1763
1764 tokio::spawn(async move {
1765 consumer.start(ctx).await.unwrap();
1766 });
1767
1768 let mut received = Vec::new();
1769 let timeout = tokio::time::timeout(Duration::from_secs(2), async {
1770 while let Some(envelope) = rx.recv().await {
1771 received.push(envelope.exchange);
1772 if received.len() == 2 {
1773 break;
1774 }
1775 }
1776 })
1777 .await;
1778 token.cancel();
1779
1780 assert!(timeout.is_ok(), "Should have received 2 exchanges");
1781 assert_eq!(received.len(), 2);
1782
1783 for ex in &received {
1784 assert!(ex.input.header("CamelFileName").is_some());
1785 assert!(ex.input.header("CamelFileNameOnly").is_some());
1786 assert!(ex.input.header("CamelFileAbsolutePath").is_some());
1787 assert!(ex.input.header("CamelFileLength").is_some());
1788 assert!(ex.input.header("CamelFileLastModified").is_some());
1789 }
1790 }
1791
1792 #[tokio::test]
1793 async fn noop_second_poll_does_not_re_emit_seen_files() {
1794 let dir = tempfile::tempdir().unwrap();
1795 let file_path = dir.path().join("test.txt");
1796 tokio::fs::write(&file_path, b"hello").await.unwrap();
1797
1798 let uri = format!(
1799 "file:{}?noop=true&initialDelay=0&delay=50",
1800 dir.path().display()
1801 );
1802 let config = FileConfig::from_uri(&uri).unwrap();
1803 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1804 let token = CancellationToken::new();
1805 let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
1806
1807 let filters = CompiledFilters::default();
1808 let mut seen = std::collections::HashSet::new();
1809 let in_process_locks = std::sync::Arc::new(DashMap::new());
1810 let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
1811
1812 poll_directory(
1813 &config,
1814 &ctx,
1815 &rt(),
1816 &filters,
1817 &mut seen,
1818 &in_process_locks,
1819 &idempotent_repo,
1820 )
1821 .await
1822 .unwrap();
1823 assert!(rx.try_recv().is_ok(), "first poll should emit file");
1824 assert!(rx.try_recv().is_err(), "should only emit once");
1825
1826 poll_directory(
1827 &config,
1828 &ctx,
1829 &rt(),
1830 &filters,
1831 &mut seen,
1832 &in_process_locks,
1833 &idempotent_repo,
1834 )
1835 .await
1836 .unwrap();
1837 assert!(
1838 rx.try_recv().is_err(),
1839 "second poll should not re-emit seen file"
1840 );
1841 }
1842
1843 #[tokio::test]
1844 async fn noop_new_files_picked_up_after_first_poll() {
1845 let dir = tempfile::tempdir().unwrap();
1846 let file1 = dir.path().join("a.txt");
1847 tokio::fs::write(&file1, b"a").await.unwrap();
1848
1849 let uri = format!(
1850 "file:{}?noop=true&initialDelay=0&delay=50",
1851 dir.path().display()
1852 );
1853 let config = FileConfig::from_uri(&uri).unwrap();
1854 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1855 let token = CancellationToken::new();
1856 let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
1857
1858 let filters = CompiledFilters::default();
1859 let mut seen = std::collections::HashSet::new();
1860 let in_process_locks = std::sync::Arc::new(DashMap::new());
1861 let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
1862
1863 poll_directory(
1864 &config,
1865 &ctx,
1866 &rt(),
1867 &filters,
1868 &mut seen,
1869 &in_process_locks,
1870 &idempotent_repo,
1871 )
1872 .await
1873 .unwrap();
1874 let _ = rx.try_recv();
1875
1876 let file2 = dir.path().join("b.txt");
1877 tokio::fs::write(&file2, b"b").await.unwrap();
1878
1879 poll_directory(
1880 &config,
1881 &ctx,
1882 &rt(),
1883 &filters,
1884 &mut seen,
1885 &in_process_locks,
1886 &idempotent_repo,
1887 )
1888 .await
1889 .unwrap();
1890 assert!(
1891 rx.try_recv().is_ok(),
1892 "b.txt should be emitted on second poll"
1893 );
1894 assert!(rx.try_recv().is_err(), "a.txt should not be re-emitted");
1895 }
1896
1897 #[tokio::test]
1898 async fn test_file_consumer_include_filter() {
1899 let dir = tempfile::tempdir().unwrap();
1900 let dir_path = dir.path().to_str().unwrap();
1901
1902 std::fs::write(dir.path().join("data.csv"), "a,b,c").unwrap();
1903 std::fs::write(dir.path().join("readme.txt"), "hello").unwrap();
1904
1905 let component = FileComponent::new();
1906 let ctx = NoOpComponentContext;
1907 let endpoint = component
1908 .create_endpoint(
1909 &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&include=.*\\.csv"),
1910 &ctx,
1911 )
1912 .unwrap();
1913 let mut consumer = endpoint.create_consumer(rt()).unwrap();
1914
1915 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1916 let token = CancellationToken::new();
1917 let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
1918
1919 tokio::spawn(async move {
1920 consumer.start(ctx).await.unwrap();
1921 });
1922
1923 let mut received = Vec::new();
1924 let _ = tokio::time::timeout(Duration::from_millis(500), async {
1925 while let Some(envelope) = rx.recv().await {
1926 received.push(envelope.exchange);
1927 if received.len() == 1 {
1928 break;
1929 }
1930 }
1931 })
1932 .await;
1933 token.cancel();
1934
1935 assert_eq!(received.len(), 1);
1936 let name = received[0]
1937 .input
1938 .header("CamelFileNameOnly")
1939 .and_then(|v| v.as_str())
1940 .unwrap();
1941 assert_eq!(name, "data.csv");
1942 }
1943
1944 #[tokio::test]
1945 async fn test_file_consumer_delete_mode() {
1946 let dir = tempfile::tempdir().unwrap();
1947 let dir_path = dir.path().to_str().unwrap();
1948
1949 std::fs::write(dir.path().join("deleteme.txt"), "bye").unwrap();
1950
1951 let component = FileComponent::new();
1952 let ctx = NoOpComponentContext;
1953 let endpoint = component
1954 .create_endpoint(
1955 &format!("file:{dir_path}?delete=true&initialDelay=0&delay=100"),
1956 &ctx,
1957 )
1958 .unwrap();
1959 let mut consumer = endpoint.create_consumer(rt()).unwrap();
1960
1961 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1962 let token = CancellationToken::new();
1963 let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
1964
1965 tokio::spawn(async move {
1966 consumer.start(ctx).await.unwrap();
1967 });
1968
1969 let _ = tokio::time::timeout(Duration::from_millis(500), async { rx.recv().await }).await;
1970 token.cancel();
1971
1972 tokio::time::sleep(Duration::from_millis(100)).await;
1973
1974 assert!(
1975 !dir.path().join("deleteme.txt").exists(),
1976 "File should be deleted"
1977 );
1978 }
1979
1980 #[tokio::test]
1981 async fn test_file_consumer_move_mode() {
1982 let dir = tempfile::tempdir().unwrap();
1983 tokio::fs::write(dir.path().join("moveme.txt"), b"data")
1984 .await
1985 .unwrap();
1986
1987 let uri = format!("file:{}?initialDelay=0&delay=50", dir.path().display());
1988 let config = FileConfig::from_uri(&uri).unwrap();
1989 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1990 let token = CancellationToken::new();
1991 let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
1992
1993 let filters = CompiledFilters::default();
1994 let mut seen = std::collections::HashSet::new();
1995 let in_process_locks = std::sync::Arc::new(DashMap::new());
1996 let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
1997
1998 poll_directory(
1999 &config,
2000 &ctx,
2001 &rt(),
2002 &filters,
2003 &mut seen,
2004 &in_process_locks,
2005 &idempotent_repo,
2006 )
2007 .await
2008 .unwrap();
2009
2010 let ex = rx.try_recv().expect("should receive exchange");
2011 drop(ex);
2012
2013 assert!(
2014 !dir.path().join("moveme.txt").exists(),
2015 "Original file should be gone"
2016 );
2017 assert!(
2018 dir.path().join(".camel").join("moveme.txt").exists(),
2019 "File should be in .camel/"
2020 );
2021 }
2022
2023 #[tokio::test]
2024 async fn test_file_consumer_respects_cancellation() {
2025 let dir = tempfile::tempdir().unwrap();
2026 let dir_path = dir.path().to_str().unwrap();
2027
2028 let component = FileComponent::new();
2029 let ctx = NoOpComponentContext;
2030 let endpoint = component
2031 .create_endpoint(&format!("file:{dir_path}?initialDelay=0&delay=50"), &ctx)
2032 .unwrap();
2033 let mut consumer = endpoint.create_consumer(rt()).unwrap();
2034
2035 let (tx, _rx) = tokio::sync::mpsc::channel(16);
2036 let token = CancellationToken::new();
2037 let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
2038
2039 let handle = tokio::spawn(async move {
2040 consumer.start(ctx).await.unwrap();
2041 });
2042
2043 tokio::time::sleep(Duration::from_millis(150)).await;
2044 token.cancel();
2045
2046 let result = tokio::time::timeout(Duration::from_secs(1), handle).await;
2047 assert!(
2048 result.is_ok(),
2049 "Consumer should have stopped after cancellation"
2050 );
2051 }
2052
2053 #[tokio::test]
2058 async fn test_file_producer_writes_file() {
2059 use tower::ServiceExt;
2060
2061 let dir = tempfile::tempdir().unwrap();
2062 let dir_path = dir.path().to_str().unwrap();
2063
2064 let component = FileComponent::new();
2065 let ctx = NoOpComponentContext;
2066 let endpoint = component
2067 .create_endpoint(&format!("file:{dir_path}"), &ctx)
2068 .unwrap();
2069 let ctx = test_producer_ctx();
2070 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2071
2072 let mut exchange = Exchange::new(Message::new("file content"));
2073 exchange.input.set_header(
2074 "CamelFileName",
2075 serde_json::Value::String("output.txt".to_string()),
2076 );
2077
2078 let result = producer.oneshot(exchange).await.unwrap();
2079
2080 let content = std::fs::read_to_string(dir.path().join("output.txt")).unwrap();
2081 assert_eq!(content, "file content");
2082
2083 assert!(result.input.header("CamelFileNameProduced").is_some());
2084 }
2085
2086 #[tokio::test]
2087 async fn test_file_producer_auto_create_dirs() {
2088 use tower::ServiceExt;
2089
2090 let dir = tempfile::tempdir().unwrap();
2091 let dir_path = dir.path().to_str().unwrap();
2092
2093 let component = FileComponent::new();
2094 let ctx = NoOpComponentContext;
2095 let endpoint = component
2096 .create_endpoint(&format!("file:{dir_path}/sub/dir"), &ctx)
2097 .unwrap();
2098 let ctx = test_producer_ctx();
2099 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2100
2101 let mut exchange = Exchange::new(Message::new("nested"));
2102 exchange.input.set_header(
2103 "CamelFileName",
2104 serde_json::Value::String("file.txt".to_string()),
2105 );
2106
2107 producer.oneshot(exchange).await.unwrap();
2108
2109 assert!(dir.path().join("sub/dir/file.txt").exists());
2110 }
2111
2112 #[tokio::test]
2113 async fn test_file_producer_file_exist_fail() {
2114 use tower::ServiceExt;
2115
2116 let dir = tempfile::tempdir().unwrap();
2117 let dir_path = dir.path().to_str().unwrap();
2118
2119 std::fs::write(dir.path().join("existing.txt"), "old").unwrap();
2120
2121 let component = FileComponent::new();
2122 let ctx = NoOpComponentContext;
2123 let endpoint = component
2124 .create_endpoint(&format!("file:{dir_path}?fileExist=Fail"), &ctx)
2125 .unwrap();
2126 let ctx = test_producer_ctx();
2127 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2128
2129 let mut exchange = Exchange::new(Message::new("new"));
2130 exchange.input.set_header(
2131 "CamelFileName",
2132 serde_json::Value::String("existing.txt".to_string()),
2133 );
2134
2135 let result = producer.oneshot(exchange).await;
2136 assert!(
2137 result.is_err(),
2138 "Should fail when file exists with Fail strategy"
2139 );
2140 }
2141
2142 #[tokio::test]
2143 async fn test_file_producer_file_exist_append() {
2144 use tower::ServiceExt;
2145
2146 let dir = tempfile::tempdir().unwrap();
2147 let dir_path = dir.path().to_str().unwrap();
2148
2149 std::fs::write(dir.path().join("append.txt"), "old").unwrap();
2150
2151 let component = FileComponent::new();
2152 let ctx = NoOpComponentContext;
2153 let endpoint = component
2154 .create_endpoint(&format!("file:{dir_path}?fileExist=Append"), &ctx)
2155 .unwrap();
2156 let ctx = test_producer_ctx();
2157 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2158
2159 let mut exchange = Exchange::new(Message::new("new"));
2160 exchange.input.set_header(
2161 "CamelFileName",
2162 serde_json::Value::String("append.txt".to_string()),
2163 );
2164
2165 producer.oneshot(exchange).await.unwrap();
2166
2167 let content = std::fs::read_to_string(dir.path().join("append.txt")).unwrap();
2168 assert_eq!(content, "oldnew");
2169 }
2170
2171 #[tokio::test]
2172 async fn test_file_producer_temp_prefix() {
2173 use tower::ServiceExt;
2174
2175 let dir = tempfile::tempdir().unwrap();
2176 let dir_path = dir.path().to_str().unwrap();
2177
2178 let component = FileComponent::new();
2179 let ctx = NoOpComponentContext;
2180 let endpoint = component
2181 .create_endpoint(&format!("file:{dir_path}?tempPrefix=.tmp"), &ctx)
2182 .unwrap();
2183 let ctx = test_producer_ctx();
2184 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2185
2186 let mut exchange = Exchange::new(Message::new("atomic write"));
2187 exchange.input.set_header(
2188 "CamelFileName",
2189 serde_json::Value::String("final.txt".to_string()),
2190 );
2191
2192 producer.oneshot(exchange).await.unwrap();
2193
2194 assert!(dir.path().join("final.txt").exists());
2195 assert!(!dir.path().join(".tmpfinal.txt").exists());
2196 let content = std::fs::read_to_string(dir.path().join("final.txt")).unwrap();
2197 assert_eq!(content, "atomic write");
2198 }
2199
2200 #[tokio::test]
2201 async fn file_producer_writes_nested_filename_override() {
2202 use tower::ServiceExt;
2203
2204 let dir = tempfile::tempdir().unwrap();
2205 let dir_path = dir.path().to_str().unwrap();
2206
2207 let component = FileComponent::new();
2208 let ctx = NoOpComponentContext;
2209 let endpoint = component
2211 .create_endpoint(&format!("file:{dir_path}"), &ctx)
2212 .unwrap();
2213 let ctx = test_producer_ctx();
2214 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2215
2216 let mut exchange = Exchange::new(Message::new("nested body"));
2217 exchange.input.set_header(
2218 "CamelFileName",
2219 serde_json::Value::String("sub/dir/payload.bin".to_string()),
2220 );
2221
2222 producer.oneshot(exchange).await.unwrap();
2223
2224 let target = dir.path().join("sub").join("dir").join("payload.bin");
2225 assert!(target.exists(), "target file should exist at {:?}", target);
2226 let content = std::fs::read(&target).unwrap();
2227 assert_eq!(content, b"nested body");
2228 assert!(
2231 !dir.path().join(".tmp.sub").exists(),
2232 "no stray temp path should be created at directory root"
2233 );
2234 }
2235
2236 #[tokio::test]
2237 async fn file_producer_writes_nested_filename_try_rename() {
2238 use tower::ServiceExt;
2239
2240 let dir = tempfile::tempdir().unwrap();
2241 let dir_path = dir.path().to_str().unwrap();
2242
2243 let component = FileComponent::new();
2244 let ctx = NoOpComponentContext;
2245 let endpoint = component
2246 .create_endpoint(
2247 &format!("file:{dir_path}?fileExist=TryRename&tempPrefix=.t."),
2248 &ctx,
2249 )
2250 .unwrap();
2251 let ctx = test_producer_ctx();
2252 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2253
2254 let mut exchange = Exchange::new(Message::new("try-rename nested"));
2255 exchange.input.set_header(
2256 "CamelFileName",
2257 serde_json::Value::String("deep/dir/x.bin".to_string()),
2258 );
2259
2260 producer.oneshot(exchange).await.unwrap();
2261
2262 let target = dir.path().join("deep").join("dir").join("x.bin");
2263 assert!(target.exists());
2264 assert_eq!(std::fs::read(&target).unwrap(), b"try-rename nested");
2265 assert!(
2267 !dir.path()
2268 .join("deep")
2269 .join("dir")
2270 .join(".t.x.bin")
2271 .exists(),
2272 "temp file should have been renamed away"
2273 );
2274 }
2275
2276 #[tokio::test]
2277 async fn file_producer_writes_nested_filename_fail_strategy() {
2278 use tower::ServiceExt;
2279
2280 let dir = tempfile::tempdir().unwrap();
2281 let dir_path = dir.path().to_str().unwrap();
2282
2283 let component = FileComponent::new();
2284 let ctx = NoOpComponentContext;
2285 let endpoint = component
2286 .create_endpoint(&format!("file:{dir_path}?fileExist=Fail"), &ctx)
2287 .unwrap();
2288 let ctx = test_producer_ctx();
2289 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2290
2291 let mut exchange = Exchange::new(Message::new("fail-strategy nested"));
2292 exchange.input.set_header(
2293 "CamelFileName",
2294 serde_json::Value::String("a/b/c.bin".to_string()),
2295 );
2296
2297 producer.oneshot(exchange).await.unwrap();
2298
2299 let target = dir.path().join("a").join("b").join("c.bin");
2300 assert!(target.exists());
2301 assert_eq!(std::fs::read(&target).unwrap(), b"fail-strategy nested");
2302 }
2303
2304 #[tokio::test]
2305 async fn test_file_producer_uses_filename_option() {
2306 use tower::ServiceExt;
2307
2308 let dir = tempfile::tempdir().unwrap();
2309 let dir_path = dir.path().to_str().unwrap();
2310
2311 let component = FileComponent::new();
2312 let ctx = NoOpComponentContext;
2313 let endpoint = component
2314 .create_endpoint(&format!("file:{dir_path}?fileName=fixed.txt"), &ctx)
2315 .unwrap();
2316 let ctx = test_producer_ctx();
2317 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2318
2319 let exchange = Exchange::new(Message::new("content"));
2320
2321 producer.oneshot(exchange).await.unwrap();
2322 assert!(dir.path().join("fixed.txt").exists());
2323 }
2324
2325 #[tokio::test]
2326 async fn test_file_producer_no_filename_errors() {
2327 use tower::ServiceExt;
2328
2329 let dir = tempfile::tempdir().unwrap();
2330 let dir_path = dir.path().to_str().unwrap();
2331
2332 let component = FileComponent::new();
2333 let ctx = NoOpComponentContext;
2334 let endpoint = component
2335 .create_endpoint(&format!("file:{dir_path}"), &ctx)
2336 .unwrap();
2337 let ctx = test_producer_ctx();
2338 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2339
2340 let exchange = Exchange::new(Message::new("content"));
2341
2342 let result = producer.oneshot(exchange).await;
2343 assert!(result.is_err(), "Should error when no filename is provided");
2344 }
2345
2346 #[tokio::test]
2351 async fn test_file_producer_rejects_path_traversal_parent_directory() {
2352 use tower::ServiceExt;
2353
2354 let dir = tempfile::tempdir().unwrap();
2355 let dir_path = dir.path().to_str().unwrap();
2356
2357 std::fs::create_dir(dir.path().join("subdir")).unwrap();
2359 std::fs::write(dir.path().join("secret.txt"), "secret").unwrap();
2360
2361 let component = FileComponent::new();
2362 let ctx = NoOpComponentContext;
2363 let endpoint = component
2364 .create_endpoint(&format!("file:{dir_path}/subdir"), &ctx)
2365 .unwrap();
2366 let ctx = test_producer_ctx();
2367 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2368
2369 let mut exchange = Exchange::new(Message::new("malicious"));
2370 exchange.input.set_header(
2371 "CamelFileName",
2372 serde_json::Value::String("../secret.txt".to_string()),
2373 );
2374
2375 let result = producer.oneshot(exchange).await;
2376 assert!(result.is_err(), "Should reject path traversal attempt");
2377
2378 let err = result.unwrap_err();
2379 let msg = err.to_string();
2382 assert!(
2383 msg.contains("outside") || msg.contains("traversal"),
2384 "Error should mention path confinement violation, got: {msg}"
2385 );
2386 }
2387
2388 #[tokio::test]
2389 async fn test_file_producer_rejects_absolute_path_outside_base() {
2390 use tower::ServiceExt;
2391
2392 let dir = tempfile::tempdir().unwrap();
2393 let dir_path = dir.path().to_str().unwrap();
2394
2395 let component = FileComponent::new();
2396 let ctx = NoOpComponentContext;
2397 let endpoint = component
2398 .create_endpoint(&format!("file:{dir_path}"), &ctx)
2399 .unwrap();
2400 let ctx = test_producer_ctx();
2401 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2402
2403 let mut exchange = Exchange::new(Message::new("malicious"));
2404 exchange.input.set_header(
2405 "CamelFileName",
2406 serde_json::Value::String("/etc/passwd".to_string()),
2407 );
2408
2409 let result = producer.oneshot(exchange).await;
2410 assert!(result.is_err(), "Should reject absolute path outside base");
2411 }
2412
2413 #[tokio::test]
2414 async fn test_file_producer_does_not_create_dirs_before_path_validation() {
2415 use tower::ServiceExt;
2416
2417 let dir = tempfile::tempdir().unwrap();
2418 let dir_path = dir.path().to_str().unwrap();
2419
2420 let component = FileComponent::new();
2421 let ctx = NoOpComponentContext;
2422 let endpoint = component
2423 .create_endpoint(&format!("file:{dir_path}"), &ctx)
2424 .unwrap();
2425 let ctx = test_producer_ctx();
2426 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2427
2428 let outside_parent = dir.path().parent().unwrap().join("escaped-create-dir");
2429 if outside_parent.exists() {
2430 std::fs::remove_dir_all(&outside_parent).unwrap();
2431 }
2432
2433 let mut exchange = Exchange::new(Message::new("malicious"));
2434 exchange.input.set_header(
2435 "CamelFileName",
2436 serde_json::Value::String("../escaped-create-dir/file.txt".to_string()),
2437 );
2438
2439 let result = producer.oneshot(exchange).await;
2440 assert!(result.is_err(), "Should reject traversal path");
2441 assert!(
2442 !outside_parent.exists(),
2443 "Must not create outside directories before path validation"
2444 );
2445 }
2446
2447 #[cfg(unix)]
2448 #[tokio::test]
2449 async fn test_list_files_skips_symlink_cycle() {
2450 use std::os::unix::fs as unix_fs;
2451
2452 let dir = tempfile::tempdir().unwrap();
2453 let nested = dir.path().join("nested");
2454 std::fs::create_dir_all(&nested).unwrap();
2455 std::fs::write(nested.join("a.txt"), "x").unwrap();
2456 unix_fs::symlink(dir.path(), nested.join("loop")).unwrap();
2457
2458 let files = list_files(dir.path(), true).await.unwrap();
2459 assert_eq!(files.iter().filter(|p| p.ends_with("a.txt")).count(), 1);
2460 }
2461
2462 #[tokio::test]
2467 #[ignore = "slow test: file polling (run with --ignored)"]
2468 async fn test_large_file_streaming_constant_memory() {
2469 use std::io::Write;
2470 use tempfile::NamedTempFile;
2471
2472 let mut temp_file = NamedTempFile::new().unwrap();
2474 let file_size = 150 * 1024 * 1024; let chunk = vec![b'X'; 1024 * 1024]; for _ in 0..150 {
2478 temp_file.write_all(&chunk).unwrap();
2479 }
2480 temp_file.flush().unwrap();
2481
2482 let dir = temp_file.path().parent().unwrap();
2483 let dir_path = dir.to_str().unwrap();
2484 let file_name = temp_file
2485 .path()
2486 .file_name()
2487 .unwrap()
2488 .to_str()
2489 .unwrap()
2490 .to_string();
2491
2492 let component = FileComponent::new();
2494 let component_ctx = NoOpComponentContext;
2495 let endpoint = component
2496 .create_endpoint(
2497 &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&fileName={file_name}"),
2498 &component_ctx,
2499 )
2500 .unwrap();
2501 let mut consumer = endpoint.create_consumer(rt()).unwrap();
2502
2503 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
2504 let token = CancellationToken::new();
2505 let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
2506
2507 tokio::spawn(async move {
2508 let _ = consumer.start(ctx).await;
2509 });
2510
2511 let exchange = tokio::time::timeout(Duration::from_secs(5), async {
2512 rx.recv().await.unwrap().exchange
2513 })
2514 .await
2515 .expect("Should receive exchange");
2516 token.cancel();
2517
2518 assert!(matches!(exchange.input.body, Body::Stream(_)));
2520
2521 if let Body::Stream(ref stream_body) = exchange.input.body {
2523 assert!(stream_body.metadata.size_hint.is_some());
2524 let size = stream_body.metadata.size_hint.unwrap();
2525 assert_eq!(size, file_size as u64);
2526 }
2527
2528 if let Body::Stream(stream_body) = exchange.input.body {
2530 let body = Body::Stream(stream_body);
2531 let result = body.into_bytes(100 * 1024 * 1024).await;
2532 assert!(result.is_err());
2533 }
2534
2535 let component2 = FileComponent::new();
2538 let endpoint2 = component2
2539 .create_endpoint(
2540 &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&fileName={file_name}"),
2541 &component_ctx,
2542 )
2543 .unwrap();
2544 let mut consumer2 = endpoint2.create_consumer(rt()).unwrap();
2545
2546 let (tx2, mut rx2) = tokio::sync::mpsc::channel(16);
2547 let token2 = CancellationToken::new();
2548 let ctx2 = ConsumerContext::new(tx2, token2.clone(), "file-test-route-2".to_string());
2549
2550 tokio::spawn(async move {
2551 let _ = consumer2.start(ctx2).await;
2552 });
2553
2554 let exchange2 = tokio::time::timeout(Duration::from_secs(5), async {
2555 rx2.recv().await.unwrap().exchange
2556 })
2557 .await
2558 .expect("Should receive exchange");
2559 token2.cancel();
2560
2561 if let Body::Stream(stream_body) = exchange2.input.body {
2562 let mut stream_lock = stream_body.stream.lock().await;
2563 let mut stream = stream_lock.take().unwrap();
2564
2565 if let Some(chunk_result) = stream.next().await {
2567 let chunk = chunk_result.unwrap();
2568 assert!(!chunk.is_empty());
2569 assert!(chunk.len() < file_size);
2570 }
2572 }
2573 }
2574
2575 #[tokio::test]
2580 async fn test_producer_writes_stream_body() {
2581 let dir = tempfile::tempdir().unwrap();
2582 let dir_path = dir.path().to_str().unwrap();
2583 let uri = format!("file:{dir_path}?fileName=out.txt");
2584
2585 let component = FileComponent::new();
2586 let ctx = NoOpComponentContext;
2587 let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2588 let producer = endpoint
2589 .create_producer(rt(), &test_producer_ctx())
2590 .unwrap();
2591
2592 let chunks: Vec<Result<Bytes, CamelError>> = vec![
2593 Ok(Bytes::from("hello ")),
2594 Ok(Bytes::from("streaming ")),
2595 Ok(Bytes::from("world")),
2596 ];
2597 let stream = futures::stream::iter(chunks);
2598 let body = Body::Stream(StreamBody {
2599 stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2600 metadata: StreamMetadata {
2601 size_hint: None,
2602 content_type: None,
2603 origin: None,
2604 },
2605 });
2606
2607 let exchange = Exchange::new(Message::new(body));
2608 tower::ServiceExt::oneshot(producer, exchange)
2609 .await
2610 .unwrap();
2611
2612 let content = tokio::fs::read_to_string(format!("{dir_path}/out.txt"))
2613 .await
2614 .unwrap();
2615 assert_eq!(content, "hello streaming world");
2616 }
2617
2618 #[tokio::test]
2619 async fn test_producer_stream_atomic_no_partial_on_error() {
2620 let dir = tempfile::tempdir().unwrap();
2622 let dir_path = dir.path().to_str().unwrap();
2623 let uri = format!("file:{dir_path}?fileName=out.txt");
2624
2625 let component = FileComponent::new();
2626 let ctx = NoOpComponentContext;
2627 let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2628 let producer = endpoint
2629 .create_producer(rt(), &test_producer_ctx())
2630 .unwrap();
2631
2632 let chunks: Vec<Result<Bytes, CamelError>> = vec![
2633 Ok(Bytes::from("partial")),
2634 Err(CamelError::ProcessorError(
2635 "simulated stream error".to_string(),
2636 )),
2637 ];
2638 let stream = futures::stream::iter(chunks);
2639 let body = Body::Stream(StreamBody {
2640 stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2641 metadata: StreamMetadata {
2642 size_hint: None,
2643 content_type: None,
2644 origin: None,
2645 },
2646 });
2647
2648 let exchange = Exchange::new(Message::new(body));
2649 let result = tower::ServiceExt::oneshot(producer, exchange).await;
2650 assert!(
2651 result.is_err(),
2652 "expected error when stream fails mid-write"
2653 );
2654
2655 assert!(
2657 !std::path::Path::new(&format!("{dir_path}/out.txt")).exists(),
2658 "partial file must not exist after failed write"
2659 );
2660
2661 assert!(
2663 !std::path::Path::new(&format!("{dir_path}/.tmp.out.txt")).exists(),
2664 "temp file must be cleaned up after failed write"
2665 );
2666 }
2667
2668 #[tokio::test]
2669 async fn test_producer_stream_append() {
2670 let dir = tempfile::tempdir().unwrap();
2671 let dir_path = dir.path().to_str().unwrap();
2672 let target = format!("{dir_path}/out.txt");
2673
2674 tokio::fs::write(&target, b"line1\n").await.unwrap();
2676
2677 let uri = format!("file:{dir_path}?fileName=out.txt&fileExist=Append");
2678 let component = FileComponent::new();
2679 let ctx = NoOpComponentContext;
2680 let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2681 let producer = endpoint
2682 .create_producer(rt(), &test_producer_ctx())
2683 .unwrap();
2684
2685 let chunks: Vec<Result<Bytes, CamelError>> = vec![Ok(Bytes::from("line2\n"))];
2686 let stream = futures::stream::iter(chunks);
2687 let body = Body::Stream(StreamBody {
2688 stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2689 metadata: StreamMetadata {
2690 size_hint: None,
2691 content_type: None,
2692 origin: None,
2693 },
2694 });
2695
2696 let exchange = Exchange::new(Message::new(body));
2697 tower::ServiceExt::oneshot(producer, exchange)
2698 .await
2699 .unwrap();
2700
2701 let content = tokio::fs::read_to_string(&target).await.unwrap();
2702 assert_eq!(content, "line1\nline2\n");
2703 }
2704
2705 #[tokio::test]
2706 async fn test_producer_stream_append_partial_on_error() {
2707 let dir = tempfile::tempdir().unwrap();
2710 let dir_path = dir.path().to_str().unwrap();
2711 let target = format!("{dir_path}/out.txt");
2712
2713 tokio::fs::write(&target, b"initial\n").await.unwrap();
2715
2716 let uri = format!("file:{dir_path}?fileName=out.txt&fileExist=Append");
2717 let component = FileComponent::new();
2718 let ctx = NoOpComponentContext;
2719 let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2720 let producer = endpoint
2721 .create_producer(rt(), &test_producer_ctx())
2722 .unwrap();
2723
2724 let chunks: Vec<Result<Bytes, CamelError>> = vec![
2726 Ok(Bytes::from("partial-")), Err(CamelError::ProcessorError("stream error".to_string())), Ok(Bytes::from("never-written")), ];
2730 let stream = futures::stream::iter(chunks);
2731 let body = Body::Stream(StreamBody {
2732 stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2733 metadata: StreamMetadata {
2734 size_hint: None,
2735 content_type: None,
2736 origin: None,
2737 },
2738 });
2739
2740 let exchange = Exchange::new(Message::new(body));
2741 let result = tower::ServiceExt::oneshot(producer, exchange).await;
2742
2743 assert!(
2745 result.is_err(),
2746 "expected error when stream fails during append"
2747 );
2748
2749 let content = tokio::fs::read_to_string(&target).await.unwrap();
2751 assert_eq!(
2752 content, "initial\npartial-",
2753 "append leaves partial data on stream error (non-atomic by nature)"
2754 );
2755 }
2756
2757 #[tokio::test]
2758 async fn test_producer_stream_already_consumed_errors() {
2759 let dir = tempfile::tempdir().unwrap();
2760 let dir_path = dir.path().to_str().unwrap();
2761 let uri = format!("file:{dir_path}?fileName=out.txt");
2762
2763 let component = FileComponent::new();
2764 let ctx = NoOpComponentContext;
2765 let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2766 let producer = endpoint
2767 .create_producer(rt(), &test_producer_ctx())
2768 .unwrap();
2769
2770 type MaybeStream = std::sync::Arc<
2772 tokio::sync::Mutex<
2773 Option<
2774 std::pin::Pin<
2775 Box<dyn futures::Stream<Item = Result<Bytes, CamelError>> + Send>,
2776 >,
2777 >,
2778 >,
2779 >;
2780 let arc: MaybeStream = std::sync::Arc::new(tokio::sync::Mutex::new(None));
2781 let body = Body::Stream(StreamBody {
2782 stream: arc,
2783 metadata: StreamMetadata {
2784 size_hint: None,
2785 content_type: None,
2786 origin: None,
2787 },
2788 });
2789
2790 let exchange = Exchange::new(Message::new(body));
2791 let result = tower::ServiceExt::oneshot(producer, exchange).await;
2792 assert!(
2793 result.is_err(),
2794 "expected error for already-consumed stream"
2795 );
2796 }
2797
2798 #[test]
2803 fn test_global_config_applied_to_endpoint() {
2804 let global = FileGlobalConfig::default()
2806 .with_delay_ms(2000)
2807 .with_initial_delay_ms(5000)
2808 .with_read_timeout_ms(60_000)
2809 .with_write_timeout_ms(45_000);
2810 let component = FileComponent::with_config(global);
2811 let ctx = NoOpComponentContext;
2812 let endpoint = component.create_endpoint("file:/tmp/inbox", &ctx).unwrap();
2814 let mut config = FileConfig::from_uri("file:/tmp/inbox").unwrap();
2817 let global2 = FileGlobalConfig::default()
2818 .with_delay_ms(2000)
2819 .with_initial_delay_ms(5000)
2820 .with_read_timeout_ms(60_000)
2821 .with_write_timeout_ms(45_000);
2822 config.apply_global_defaults(&global2);
2823 assert_eq!(config.delay, Duration::from_millis(2000));
2824 assert_eq!(config.initial_delay, Duration::from_millis(5000));
2825 assert_eq!(config.read_timeout, Duration::from_millis(60_000));
2826 assert_eq!(config.write_timeout, Duration::from_millis(45_000));
2827 let _ = endpoint; }
2830
2831 #[test]
2832 fn test_uri_param_wins_over_global_config() {
2833 let mut config =
2835 FileConfig::from_uri("file:/tmp/inbox?delay=1000&initialDelay=2000").unwrap();
2836 let global = FileGlobalConfig::default()
2838 .with_delay_ms(3000)
2839 .with_initial_delay_ms(4000);
2840 config.apply_global_defaults(&global);
2841 assert_eq!(config.delay, Duration::from_millis(1000));
2843 assert_eq!(config.initial_delay, Duration::from_millis(2000));
2845 assert_eq!(config.read_timeout, Duration::from_millis(30_000));
2848 }
2849
2850 #[tokio::test]
2851 async fn test_file_producer_filename_simple_language_from_header() {
2852 use tower::ServiceExt;
2853
2854 let dir = tempfile::tempdir().unwrap();
2855 let dir_path = dir.path().to_str().unwrap();
2856
2857 let component = FileComponent::new();
2858 let ctx = NoOpComponentContext;
2859 let endpoint = component
2860 .create_endpoint(&format!("file:{dir_path}"), &ctx)
2861 .unwrap();
2862 let ctx = test_producer_ctx();
2863 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2864
2865 let mut exchange = Exchange::new(Message::new("content"));
2866 exchange
2867 .input
2868 .set_header("CamelTimerCounter", serde_json::Value::Number(42.into()));
2869 exchange.input.set_header(
2870 "CamelFileName",
2871 serde_json::Value::String("test-${header.CamelTimerCounter}.txt".to_string()),
2872 );
2873
2874 producer.oneshot(exchange).await.unwrap();
2875
2876 assert!(
2877 dir.path().join("test-42.txt").exists(),
2878 "fileName should have been evaluated from Simple Language expression"
2879 );
2880 let content = std::fs::read_to_string(dir.path().join("test-42.txt")).unwrap();
2881 assert_eq!(content, "content");
2882 }
2883
2884 #[tokio::test]
2885 async fn test_file_producer_filename_simple_language_from_uri_param() {
2886 use tower::ServiceExt;
2887
2888 let dir = tempfile::tempdir().unwrap();
2889 let dir_path = dir.path().to_str().unwrap();
2890
2891 let component = FileComponent::new();
2892 let ctx = NoOpComponentContext;
2893 let endpoint = component
2894 .create_endpoint(
2895 &format!("file:{dir_path}?fileName=msg-${{header.id}}.dat"),
2896 &ctx,
2897 )
2898 .unwrap();
2899 let ctx = test_producer_ctx();
2900 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2901
2902 let mut exchange = Exchange::new(Message::new("data"));
2903 exchange
2904 .input
2905 .set_header("id", serde_json::Value::String("abc".to_string()));
2906
2907 producer.oneshot(exchange).await.unwrap();
2908
2909 assert!(
2910 dir.path().join("msg-abc.dat").exists(),
2911 "fileName URI param should have been evaluated from Simple Language expression"
2912 );
2913 }
2914
2915 #[tokio::test]
2916 async fn test_file_producer_filename_literal_without_expression() {
2917 use tower::ServiceExt;
2918
2919 let dir = tempfile::tempdir().unwrap();
2920 let dir_path = dir.path().to_str().unwrap();
2921
2922 let component = FileComponent::new();
2923 let ctx = NoOpComponentContext;
2924 let endpoint = component
2925 .create_endpoint(&format!("file:{dir_path}?fileName=plain.txt"), &ctx)
2926 .unwrap();
2927 let ctx = test_producer_ctx();
2928 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2929
2930 let exchange = Exchange::new(Message::new("data"));
2931 producer.oneshot(exchange).await.unwrap();
2932
2933 assert!(
2934 dir.path().join("plain.txt").exists(),
2935 "literal fileName without expressions should still work"
2936 );
2937 }
2938
2939 #[test]
2944 fn test_rejects_path_traversal_in_move_to() {
2945 let result = FileConfig::from_uri("file:/tmp/inbox?move=../etc/passwd");
2946 assert!(result.is_err(), "should reject path traversal in move_to");
2947 let err = result.unwrap_err().to_string();
2948 assert!(
2949 err.contains("path traversal"),
2950 "error should mention path traversal: {err}"
2951 );
2952 }
2953
2954 #[test]
2955 fn test_rejects_absolute_move_to() {
2956 let result = FileConfig::from_uri("file:/tmp/inbox?move=/tmp/outside");
2957 assert!(result.is_err(), "should reject absolute move_to");
2958 let err = result.unwrap_err().to_string();
2959 assert!(
2960 err.contains("relative path") || err.contains("Invalid URI"),
2961 "error should mention invalid move_to path: {err}"
2962 );
2963 }
2964
2965 #[test]
2966 fn test_rejects_path_traversal_in_temp_prefix() {
2967 let result = FileConfig::from_uri("file:/tmp/inbox?tempPrefix=../tmp");
2968 assert!(
2969 result.is_err(),
2970 "should reject path traversal in temp_prefix"
2971 );
2972 let err = result.unwrap_err().to_string();
2973 assert!(
2974 err.contains("path traversal"),
2975 "error should mention path traversal: {err}"
2976 );
2977 }
2978
2979 #[test]
2980 fn test_rejects_temp_prefix_with_path_separator() {
2981 let result = FileConfig::from_uri("file:/tmp/inbox?tempPrefix=tmp/sub");
2982 assert!(result.is_err(), "should reject temp_prefix with separator");
2983 let err = result.unwrap_err().to_string();
2984 assert!(
2985 err.contains("plain filename prefix"),
2986 "error should mention plain filename prefix restriction: {err}"
2987 );
2988 }
2989
2990 #[test]
2991 fn test_rejects_absolute_temp_prefix() {
2992 let result = FileConfig::from_uri("file:/tmp/inbox?tempPrefix=/tmp/");
2993 assert!(result.is_err(), "should reject absolute temp_prefix");
2994 let err = result.unwrap_err().to_string();
2995 assert!(
2996 err.contains("plain filename prefix"),
2997 "error should mention plain filename prefix restriction: {err}"
2998 );
2999 }
3000
3001 #[test]
3002 fn test_rejects_null_byte_in_temp_prefix() {
3003 let config = FileConfig {
3004 directory: "/tmp/inbox".into(),
3005 delay: Duration::from_millis(500),
3006 delay_ms: 500,
3007 initial_delay: Duration::from_millis(1000),
3008 initial_delay_ms: 1000,
3009 noop: false,
3010 delete: false,
3011 move_to: None,
3012 file_name: Some("ok.txt".into()),
3013 include: None,
3014 exclude: None,
3015 ant_include: None,
3016 ant_exclude: None,
3017 include_ext: None,
3018 exclude_ext: None,
3019 recursive: false,
3020 file_exist: FileExistStrategy::Override,
3021 read_lock_strategy: ReadLockStrategy::None,
3022 idempotent_key: IdempotentKey::None,
3023 done_file_name: None,
3024 charset: None,
3025 temp_prefix: Some("tmp\0".into()),
3026 durable: false,
3027 auto_create: true,
3028 starting_directory_must_exist: false,
3029 read_timeout: Duration::from_millis(30_000),
3030 read_timeout_ms: 30_000,
3031 write_timeout: Duration::from_millis(30_000),
3032 write_timeout_ms: 30_000,
3033 max_write_bytes: 0,
3034 max_depth: usize::MAX,
3035 min_depth: 0,
3036 max_messages_per_poll: 0,
3037 eager_max_messages_per_poll: true,
3038 shuffle: false,
3039 sort_spec: None,
3040 cleanup_stale_temps: true,
3041 };
3042
3043 let result = config.validate();
3044 assert!(result.is_err(), "should reject null byte in temp_prefix");
3045 }
3046
3047 #[test]
3048 fn test_rejects_null_byte_in_filename() {
3049 let config = FileConfig {
3052 directory: "/tmp/inbox".into(),
3053 delay: Duration::from_millis(500),
3054 delay_ms: 500,
3055 initial_delay: Duration::from_millis(1000),
3056 initial_delay_ms: 1000,
3057 noop: false,
3058 delete: false,
3059 move_to: None,
3060 file_name: Some("foo\0bar".into()),
3061 include: None,
3062 exclude: None,
3063 ant_include: None,
3064 ant_exclude: None,
3065 include_ext: None,
3066 exclude_ext: None,
3067 recursive: false,
3068 file_exist: FileExistStrategy::Override,
3069 read_lock_strategy: ReadLockStrategy::None,
3070 idempotent_key: IdempotentKey::None,
3071 done_file_name: None,
3072 charset: None,
3073 temp_prefix: None,
3074 durable: false,
3075 auto_create: true,
3076 starting_directory_must_exist: false,
3077 read_timeout: Duration::from_millis(30_000),
3078 read_timeout_ms: 30_000,
3079 write_timeout: Duration::from_millis(30_000),
3080 write_timeout_ms: 30_000,
3081 max_write_bytes: 0,
3082 max_depth: usize::MAX,
3083 min_depth: 0,
3084 max_messages_per_poll: 0,
3085 eager_max_messages_per_poll: true,
3086 shuffle: false,
3087 sort_spec: None,
3088 cleanup_stale_temps: true,
3089 };
3090 let result = config.validate();
3091 assert!(result.is_err(), "should reject null byte in filename");
3092 let err = result.unwrap_err().to_string();
3093 assert!(
3094 err.contains("null"),
3095 "error should mention null bytes: {err}"
3096 );
3097 }
3098
3099 #[test]
3100 fn test_rejects_empty_filename() {
3101 let config = FileConfig {
3102 directory: "/tmp/inbox".into(),
3103 delay: Duration::from_millis(500),
3104 delay_ms: 500,
3105 initial_delay: Duration::from_millis(1000),
3106 initial_delay_ms: 1000,
3107 noop: false,
3108 delete: false,
3109 move_to: None,
3110 file_name: Some("".into()),
3111 include: None,
3112 exclude: None,
3113 ant_include: None,
3114 ant_exclude: None,
3115 include_ext: None,
3116 exclude_ext: None,
3117 recursive: false,
3118 file_exist: FileExistStrategy::Override,
3119 read_lock_strategy: ReadLockStrategy::None,
3120 idempotent_key: IdempotentKey::None,
3121 done_file_name: None,
3122 charset: None,
3123 temp_prefix: None,
3124 durable: false,
3125 auto_create: true,
3126 starting_directory_must_exist: false,
3127 read_timeout: Duration::from_millis(30_000),
3128 read_timeout_ms: 30_000,
3129 write_timeout: Duration::from_millis(30_000),
3130 write_timeout_ms: 30_000,
3131 max_write_bytes: 0,
3132 max_depth: usize::MAX,
3133 min_depth: 0,
3134 max_messages_per_poll: 0,
3135 eager_max_messages_per_poll: true,
3136 shuffle: false,
3137 sort_spec: None,
3138 cleanup_stale_temps: true,
3139 };
3140 let result = config.validate();
3141 assert!(result.is_err(), "should reject empty filename");
3142 let err = result.unwrap_err().to_string();
3143 assert!(
3144 err.contains("empty") || err.contains("must not"),
3145 "error should mention empty: {err}"
3146 );
3147 }
3148
3149 #[test]
3150 fn test_rejects_nonexistent_directory_when_starting_directory_must_exist() {
3151 let result =
3152 FileConfig::from_uri("file:/tmp/nonexistent_dir_12345?startingDirectoryMustExist=true");
3153 assert!(
3154 result.is_err(),
3155 "should reject non-existent directory when startingDirectoryMustExist=true"
3156 );
3157 let err = result.unwrap_err().to_string();
3158 assert!(
3159 err.contains("does not exist"),
3160 "error should mention directory does not exist: {err}"
3161 );
3162 }
3163
3164 #[test]
3165 fn test_accepts_existing_directory_when_starting_directory_must_exist() {
3166 let dir = tempfile::tempdir().unwrap();
3167 let dir_path = dir.path().to_str().unwrap();
3168 let result =
3169 FileConfig::from_uri(&format!("file:{dir_path}?startingDirectoryMustExist=true"));
3170 assert!(
3171 result.is_ok(),
3172 "should accept existing directory when startingDirectoryMustExist=true: {:?}",
3173 result.err()
3174 );
3175 }
3176
3177 #[test]
3178 fn test_valid_config_passes() {
3179 let cfg = FileConfig::from_uri("file:/tmp/inbox").unwrap();
3180 assert!(cfg.validate().is_ok());
3181 }
3182
3183 #[test]
3184 fn test_file_exist_strategy_rejects_unknown() {
3185 let result = FileExistStrategy::from_str("BogusValue");
3187 assert!(
3188 result.is_err(),
3189 "unknown FileExistStrategy should return Err"
3190 );
3191 }
3192
3193 #[test]
3194 fn test_path_contains_traversal_detects_parent_dir() {
3195 assert!(path_contains_traversal("../etc/passwd"));
3196 assert!(path_contains_traversal("foo/../bar"));
3197 assert!(path_contains_traversal(".."));
3198 assert!(path_contains_traversal("a/b/../../../c"));
3199 }
3200
3201 #[test]
3202 fn test_path_contains_traversal_accepts_safe_paths() {
3203 assert!(!path_contains_traversal("safe/path"));
3204 assert!(!path_contains_traversal("/absolute/path"));
3205 assert!(!path_contains_traversal("filename.txt"));
3206 assert!(!path_contains_traversal(".hidden"));
3207 assert!(!path_contains_traversal(""));
3208 }
3209
3210 #[tokio::test]
3215 async fn test_stream_detects_file_modified_during_read() {
3216 use futures::StreamExt;
3217
3218 let dir = tempfile::tempdir().unwrap();
3219 let file_path = dir.path().join("mutable.txt");
3220 std::fs::write(&file_path, b"initial content here").unwrap();
3221
3222 let initial_meta = std::fs::metadata(&file_path).unwrap();
3224 let initial_size = initial_meta.len();
3225 let initial_mtime = initial_meta.modified().ok();
3226
3227 let file = tokio::fs::File::open(&file_path).await.unwrap();
3229 let raw_stream = ReaderStream::new(file).map(|res| res.map_err(CamelError::from));
3230
3231 let mut stream = ModificationDetectingStream::new(
3232 raw_stream,
3233 file_path.clone(),
3234 initial_size,
3235 initial_mtime,
3236 );
3237
3238 let chunk = stream.next().await;
3240 assert!(chunk.is_some(), "should produce at least one chunk");
3241 assert!(chunk.as_ref().unwrap().is_ok(), "first chunk should be Ok");
3242
3243 std::fs::write(&file_path, b"modified content - different size!!").unwrap();
3245
3246 let mut got_error = false;
3248 while let Some(result) = stream.next().await {
3249 if let Err(err) = result {
3250 let msg = err.to_string();
3251 assert!(
3252 msg.contains("file modified during read"),
3253 "expected modification error, got: {msg}"
3254 );
3255 got_error = true;
3256 break;
3257 }
3258 }
3259
3260 assert!(
3261 got_error,
3262 "stream should detect file was modified during read"
3263 );
3264 }
3265
3266 #[tokio::test]
3267 async fn test_stream_succeeds_when_file_not_modified() {
3268 use futures::StreamExt;
3269
3270 let dir = tempfile::tempdir().unwrap();
3271 let file_path = dir.path().join("stable.txt");
3272 std::fs::write(&file_path, b"stable content").unwrap();
3273
3274 let initial_meta = std::fs::metadata(&file_path).unwrap();
3275 let initial_size = initial_meta.len();
3276 let initial_mtime = initial_meta.modified().ok();
3277
3278 let file = tokio::fs::File::open(&file_path).await.unwrap();
3279 let raw_stream = ReaderStream::new(file).map(|res| res.map_err(CamelError::from));
3280
3281 let mut stream = ModificationDetectingStream::new(
3282 raw_stream,
3283 file_path.clone(),
3284 initial_size,
3285 initial_mtime,
3286 );
3287
3288 let mut all_ok = true;
3290 while let Some(result) = stream.next().await {
3291 if result.is_err() {
3292 all_ok = false;
3293 break;
3294 }
3295 }
3296
3297 assert!(
3298 all_ok,
3299 "stream should complete without errors when file is not modified"
3300 );
3301 }
3302
3303 #[test]
3308 fn test_sort_spec_bare_field() {
3309 let spec: SortSpec = "file:name".parse().unwrap();
3310 assert_eq!(spec.groups.len(), 1);
3311 let g = &spec.groups[0];
3312 assert_eq!(g.field, SortField::Name);
3313 assert!(!g.reverse);
3314 assert!(!g.ignore_case);
3315 }
3316
3317 #[test]
3318 fn test_sort_spec_reverse() {
3319 let spec: SortSpec = "reverse:file:length".parse().unwrap();
3320 assert_eq!(spec.groups.len(), 1);
3321 let g = &spec.groups[0];
3322 assert_eq!(g.field, SortField::Length);
3323 assert!(g.reverse);
3324 assert!(!g.ignore_case);
3325 }
3326
3327 #[test]
3328 fn test_sort_spec_ignore_case() {
3329 let spec: SortSpec = "ignoreCase:file:name".parse().unwrap();
3330 assert_eq!(spec.groups.len(), 1);
3331 let g = &spec.groups[0];
3332 assert_eq!(g.field, SortField::Name);
3333 assert!(!g.reverse);
3334 assert!(g.ignore_case);
3335 }
3336
3337 #[test]
3338 fn test_sort_spec_reverse_ignore_case() {
3339 let spec: SortSpec = "reverse:ignoreCase:file:modified".parse().unwrap();
3340 assert_eq!(spec.groups.len(), 1);
3341 let g = &spec.groups[0];
3342 assert_eq!(g.field, SortField::Modified);
3343 assert!(g.reverse);
3344 assert!(g.ignore_case);
3345 }
3346
3347 #[test]
3348 fn test_sort_spec_wrong_order_rejection() {
3349 let result: Result<SortSpec, _> = "ignoreCase:reverse:file:name".parse();
3350 assert!(result.is_err());
3351 let err = result.unwrap_err();
3352 assert!(
3353 err.contains("reverse must precede"),
3354 "expected 'reverse must precede' error, got: {err}"
3355 );
3356 }
3357
3358 #[test]
3359 fn test_sort_spec_multi_group() {
3360 let spec: SortSpec = "file:name;reverse:file:length".parse().unwrap();
3361 assert_eq!(spec.groups.len(), 2);
3362 assert_eq!(spec.groups[0].field, SortField::Name);
3363 assert!(!spec.groups[0].reverse);
3364 assert_eq!(spec.groups[1].field, SortField::Length);
3365 assert!(spec.groups[1].reverse);
3366 }
3367
3368 #[test]
3369 fn test_sort_spec_unsupported_field() {
3370 let result: Result<SortSpec, _> = "file:unknown".parse();
3371 assert!(result.is_err());
3372 let err = result.unwrap_err();
3373 assert!(
3374 err.contains("unsupported"),
3375 "expected 'unsupported' error, got: {err}"
3376 );
3377 }
3378
3379 #[test]
3380 fn test_sort_spec_empty_string() {
3381 let result: Result<SortSpec, _> = "".parse();
3382 assert!(result.is_err());
3383 let err = result.unwrap_err();
3384 assert!(
3385 err.contains("at least one group"),
3386 "expected 'at least one group' error, got: {err}"
3387 );
3388 }
3389
3390 #[tokio::test]
3391 async fn test_max_depth_limits_recursion() {
3392 let dir = tempfile::tempdir().unwrap();
3393 let base = dir.path();
3394 fs::create_dir_all(base.join("sub/deep")).await.unwrap();
3395 fs::write(base.join("root.txt"), b"r").await.unwrap();
3396 fs::write(base.join("sub/mid.txt"), b"m").await.unwrap();
3397 fs::write(base.join("sub/deep/leaf.txt"), b"l")
3398 .await
3399 .unwrap();
3400
3401 let config = FileConfig::from_uri(&format!(
3402 "file://{}?recursive=true&maxDepth=2",
3403 base.display()
3404 ))
3405 .unwrap();
3406 let filters = CompiledFilters::compile(&config).unwrap();
3407 let mut seen = HashSet::new();
3408 let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3409 .await
3410 .unwrap()
3411 .candidates;
3412 let names: Vec<String> = candidates
3413 .iter()
3414 .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3415 .collect();
3416 assert!(names.contains(&"root.txt".to_string()));
3417 assert!(names.contains(&"mid.txt".to_string()));
3418 assert!(!names.contains(&"leaf.txt".to_string()));
3419 }
3420
3421 #[tokio::test]
3422 async fn test_min_depth_skips_base_files() {
3423 let dir = tempfile::tempdir().unwrap();
3424 let base = dir.path();
3425 fs::create_dir_all(base.join("sub")).await.unwrap();
3426 fs::write(base.join("root.txt"), b"r").await.unwrap();
3427 fs::write(base.join("sub/deep.txt"), b"d").await.unwrap();
3428
3429 let config = FileConfig::from_uri(&format!(
3430 "file://{}?recursive=true&minDepth=2",
3431 base.display()
3432 ))
3433 .unwrap();
3434 let filters = CompiledFilters::compile(&config).unwrap();
3435 let mut seen = HashSet::new();
3436 let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3437 .await
3438 .unwrap()
3439 .candidates;
3440 let names: Vec<String> = candidates
3441 .iter()
3442 .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3443 .collect();
3444 assert!(!names.contains(&"root.txt".to_string()));
3445 assert!(names.contains(&"deep.txt".to_string()));
3446 }
3447
3448 #[tokio::test]
3449 async fn test_include_ext_filter() {
3450 let dir = tempfile::tempdir().unwrap();
3451 let base = dir.path();
3452 fs::write(base.join("data.txt"), b"t").await.unwrap();
3453 fs::write(base.join("data.csv"), b"c").await.unwrap();
3454 fs::write(base.join("data.json"), b"j").await.unwrap();
3455
3456 let config =
3457 FileConfig::from_uri(&format!("file://{}?includeExt=txt,csv", base.display())).unwrap();
3458 let filters = CompiledFilters::compile(&config).unwrap();
3459 let mut seen = HashSet::new();
3460 let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3461 .await
3462 .unwrap()
3463 .candidates;
3464 let names: Vec<String> = candidates
3465 .iter()
3466 .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3467 .collect();
3468 assert_eq!(names.len(), 2);
3469 assert!(names.contains(&"data.txt".to_string()));
3470 assert!(names.contains(&"data.csv".to_string()));
3471 }
3472
3473 #[tokio::test]
3474 async fn test_ant_include_glob() {
3475 let dir = tempfile::tempdir().unwrap();
3476 let base = dir.path();
3477 fs::write(base.join("report.txt"), b"r").await.unwrap();
3478 fs::write(base.join("data.csv"), b"d").await.unwrap();
3479 fs::write(base.join("image.png"), b"i").await.unwrap();
3480
3481 let config =
3482 FileConfig::from_uri(&format!("file://{}?antInclude=*.txt,*.csv", base.display()))
3483 .unwrap();
3484 let filters = CompiledFilters::compile(&config).unwrap();
3485 let mut seen = HashSet::new();
3486 let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3487 .await
3488 .unwrap()
3489 .candidates;
3490 let names: Vec<String> = candidates
3491 .iter()
3492 .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3493 .collect();
3494 assert_eq!(names.len(), 2);
3495 assert!(names.contains(&"report.txt".to_string()));
3496 assert!(names.contains(&"data.csv".to_string()));
3497 }
3498
3499 #[tokio::test]
3500 async fn test_exclude_ext_filter() {
3501 let dir = tempfile::tempdir().unwrap();
3502 let base = dir.path();
3503 fs::write(base.join("keep.txt"), b"k").await.unwrap();
3504 fs::write(base.join("skip.log"), b"s").await.unwrap();
3505
3506 let config =
3507 FileConfig::from_uri(&format!("file://{}?excludeExt=log", base.display())).unwrap();
3508 let filters = CompiledFilters::compile(&config).unwrap();
3509 let mut seen = HashSet::new();
3510 let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3511 .await
3512 .unwrap()
3513 .candidates;
3514 let names: Vec<String> = candidates
3515 .iter()
3516 .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3517 .collect();
3518 assert_eq!(names.len(), 1);
3519 assert!(names.contains(&"keep.txt".to_string()));
3520 }
3521
3522 #[tokio::test]
3523 async fn test_ant_exclude_glob() {
3524 let dir = tempfile::tempdir().unwrap();
3525 let base = dir.path();
3526 fs::write(base.join("report.txt"), b"r").await.unwrap();
3527 fs::write(base.join("temp.tmp"), b"t").await.unwrap();
3528
3529 let config =
3530 FileConfig::from_uri(&format!("file://{}?antExclude=*.tmp", base.display())).unwrap();
3531 let filters = CompiledFilters::compile(&config).unwrap();
3532 let mut seen = HashSet::new();
3533 let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3534 .await
3535 .unwrap()
3536 .candidates;
3537 let names: Vec<String> = candidates
3538 .iter()
3539 .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3540 .collect();
3541 assert_eq!(names.len(), 1);
3542 assert!(names.contains(&"report.txt".to_string()));
3543 }
3544
3545 #[tokio::test]
3546 async fn test_done_file_consumer_skips_without_marker() {
3547 let dir = tempfile::tempdir().unwrap();
3548 let base = dir.path();
3549 fs::write(base.join("ready.txt"), b"ready").await.unwrap();
3550 fs::write(base.join("ready.txt.done"), b"").await.unwrap();
3551 fs::write(base.join("pending.txt"), b"pending")
3552 .await
3553 .unwrap();
3554
3555 let config = FileConfig::from_uri(&format!(
3556 "file://{}?doneFileName=${{file:name}}.done",
3557 base.display()
3558 ))
3559 .unwrap();
3560 let filters = CompiledFilters::compile(&config).unwrap();
3561 let mut seen = HashSet::new();
3562 let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3563 .await
3564 .unwrap()
3565 .candidates;
3566 let names: Vec<String> = candidates
3567 .iter()
3568 .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3569 .collect();
3570 assert!(names.contains(&"ready.txt".to_string()));
3571 assert!(!names.contains(&"pending.txt".to_string()));
3572 assert!(!names.contains(&"ready.txt.done".to_string()));
3573 }
3574
3575 #[tokio::test]
3576 async fn test_done_file_static_pattern() {
3577 let dir = tempfile::tempdir().unwrap();
3578 let base = dir.path();
3579 fs::write(base.join("data.txt"), b"d").await.unwrap();
3580 fs::write(base.join("ready"), b"").await.unwrap();
3581
3582 let config =
3583 FileConfig::from_uri(&format!("file://{}?doneFileName=ready", base.display())).unwrap();
3584 let filters = CompiledFilters::compile(&config).unwrap();
3585 let mut seen = HashSet::new();
3586 let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3587 .await
3588 .unwrap()
3589 .candidates;
3590 assert_eq!(candidates.len(), 1);
3591 assert_eq!(
3592 candidates[0].path.file_name().unwrap().to_str().unwrap(),
3593 "data.txt"
3594 );
3595 }
3596
3597 #[tokio::test]
3602 async fn test_sort_by_name() {
3603 let dir = tempfile::tempdir().unwrap();
3604 let base = dir.path();
3605 fs::write(base.join("c.txt"), b"3").await.unwrap();
3606 fs::write(base.join("a.txt"), b"1").await.unwrap();
3607 fs::write(base.join("b.txt"), b"2").await.unwrap();
3608
3609 let config =
3610 FileConfig::from_uri(&format!("file://{}?sortBy=file:name", base.display())).unwrap();
3611 let filters = CompiledFilters::compile(&config).unwrap();
3612 let mut seen = HashSet::new();
3613 let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3614 .await
3615 .unwrap()
3616 .candidates;
3617 apply_sort_and_limit(&mut candidates, &config);
3618 let names: Vec<&str> = candidates
3619 .iter()
3620 .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3621 .collect();
3622 assert_eq!(names, vec!["a.txt", "b.txt", "c.txt"]);
3623 }
3624
3625 #[tokio::test]
3626 async fn test_sort_by_length() {
3627 let dir = tempfile::tempdir().unwrap();
3628 let base = dir.path();
3629 fs::write(base.join("big.txt"), b"xxx").await.unwrap();
3630 fs::write(base.join("small.txt"), b"x").await.unwrap();
3631
3632 let config =
3633 FileConfig::from_uri(&format!("file://{}?sortBy=file:length", base.display())).unwrap();
3634 let filters = CompiledFilters::compile(&config).unwrap();
3635 let mut seen = HashSet::new();
3636 let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3637 .await
3638 .unwrap()
3639 .candidates;
3640 apply_sort_and_limit(&mut candidates, &config);
3641 let names: Vec<&str> = candidates
3642 .iter()
3643 .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3644 .collect();
3645 assert_eq!(names, vec!["small.txt", "big.txt"]);
3646 }
3647
3648 #[tokio::test]
3649 async fn test_sort_by_reverse_name() {
3650 let dir = tempfile::tempdir().unwrap();
3651 let base = dir.path();
3652 fs::write(base.join("a.txt"), b"1").await.unwrap();
3653 fs::write(base.join("b.txt"), b"2").await.unwrap();
3654 fs::write(base.join("c.txt"), b"3").await.unwrap();
3655
3656 let config = FileConfig::from_uri(&format!(
3657 "file://{}?sortBy=reverse:file:name",
3658 base.display()
3659 ))
3660 .unwrap();
3661 let filters = CompiledFilters::compile(&config).unwrap();
3662 let mut seen = HashSet::new();
3663 let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3664 .await
3665 .unwrap()
3666 .candidates;
3667 apply_sort_and_limit(&mut candidates, &config);
3668 let names: Vec<&str> = candidates
3669 .iter()
3670 .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3671 .collect();
3672 assert_eq!(names, vec!["c.txt", "b.txt", "a.txt"]);
3673 }
3674
3675 #[tokio::test]
3676 async fn test_shuffle_deterministic() {
3677 let dir = tempfile::tempdir().unwrap();
3678 let base = dir.path();
3679 fs::write(base.join("a.txt"), b"1").await.unwrap();
3680 fs::write(base.join("b.txt"), b"2").await.unwrap();
3681 fs::write(base.join("c.txt"), b"3").await.unwrap();
3682 fs::write(base.join("d.txt"), b"4").await.unwrap();
3683
3684 let config =
3685 FileConfig::from_uri(&format!("file://{}?shuffle=true", base.display())).unwrap();
3686 let filters = CompiledFilters::compile(&config).unwrap();
3687 let mut seen = HashSet::new();
3688 let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3689 .await
3690 .unwrap()
3691 .candidates;
3692 crate::poll_logic::apply_sort_and_limit(&mut candidates, &config);
3693
3694 let mut candidates2 =
3695 crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3696 .await
3697 .unwrap()
3698 .candidates;
3699 crate::poll_logic::apply_sort_and_limit(&mut candidates2, &config);
3700
3701 let names1: Vec<&str> = candidates
3702 .iter()
3703 .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3704 .collect();
3705 let names2: Vec<&str> = candidates2
3706 .iter()
3707 .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3708 .collect();
3709 assert_eq!(names1, names2);
3710 assert_ne!(names1, vec!["a.txt", "b.txt", "c.txt", "d.txt"]);
3711 }
3712
3713 #[tokio::test]
3714 async fn test_non_eager_limit() {
3715 let dir = tempfile::tempdir().unwrap();
3716 let base = dir.path();
3717 fs::write(base.join("a.txt"), b"1").await.unwrap();
3718 fs::write(base.join("b.txt"), b"2").await.unwrap();
3719 fs::write(base.join("c.txt"), b"3").await.unwrap();
3720
3721 let config = FileConfig::from_uri(&format!(
3722 "file://{}?maxMessagesPerPoll=2&eagerMaxMessagesPerPoll=false",
3723 base.display()
3724 ))
3725 .unwrap();
3726 let filters = CompiledFilters::compile(&config).unwrap();
3727 let mut seen = HashSet::new();
3728 let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3729 .await
3730 .unwrap()
3731 .candidates;
3732 assert_eq!(candidates.len(), 3);
3733 crate::poll_logic::apply_sort_and_limit(&mut candidates, &config);
3734 assert_eq!(candidates.len(), 2);
3735 }
3736
3737 #[tokio::test]
3738 async fn test_poll_one_file_sets_all_headers() {
3739 let dir = tempfile::tempdir().unwrap();
3740 let base = dir.path();
3741 fs::write(base.join("test.txt"), b"hello world")
3742 .await
3743 .unwrap();
3744
3745 let config = FileConfig::from_uri(&format!("file:{}?noop=true", base.display())).unwrap();
3746
3747 let file_path = base.join("test.txt");
3748 let include_re = None;
3749 let exclude_re = None;
3750 let mut seen = std::collections::HashSet::new();
3751 let in_process_locks = std::sync::Arc::new(DashMap::new());
3752 let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
3753
3754 let result = poll_one_file(
3755 &config,
3756 file_path.clone(),
3757 base,
3758 &include_re,
3759 &exclude_re,
3760 &mut seen,
3761 &in_process_locks,
3762 &idempotent_repo,
3763 )
3764 .await
3765 .unwrap();
3766
3767 let exchange = result.expect("poll_one_file should return an exchange");
3768
3769 let camel_file_name = exchange
3770 .input
3771 .header("CamelFileName")
3772 .and_then(|v| v.as_str().map(String::from))
3773 .expect("CamelFileName header");
3774 let camel_file_name_only = exchange
3775 .input
3776 .header("CamelFileNameOnly")
3777 .and_then(|v| v.as_str().map(String::from))
3778 .expect("CamelFileNameOnly header");
3779 let camel_file_absolute_path = exchange
3780 .input
3781 .header("CamelFileAbsolutePath")
3782 .and_then(|v| v.as_str().map(String::from))
3783 .expect("CamelFileAbsolutePath header");
3784 exchange
3785 .input
3786 .header("CamelFileLength")
3787 .and_then(|v| v.as_u64())
3788 .expect("CamelFileLength header");
3789 exchange
3790 .input
3791 .header("CamelFileLastModified")
3792 .and_then(|v| v.as_u64())
3793 .expect("CamelFileLastModified header");
3794 let camel_file_path = exchange
3795 .input
3796 .header("CamelFilePath")
3797 .and_then(|v| v.as_str().map(String::from))
3798 .expect("CamelFilePath header");
3799 let camel_file_parent = exchange
3800 .input
3801 .header("CamelFileParent")
3802 .and_then(|v| v.as_str().map(String::from))
3803 .expect("CamelFileParent header");
3804 let camel_file_canonical_path = exchange
3805 .input
3806 .header("CamelFileCanonicalPath")
3807 .and_then(|v| v.as_str().map(String::from))
3808 .expect("CamelFileCanonicalPath header");
3809 let camel_file_relative_path = exchange
3810 .input
3811 .header("CamelFileRelativePath")
3812 .and_then(|v| v.as_str().map(String::from))
3813 .expect("CamelFileRelativePath header");
3814
3815 assert_eq!(camel_file_name, "test.txt");
3816 assert_eq!(camel_file_name_only, "test.txt");
3817 assert_eq!(camel_file_relative_path, camel_file_name);
3818 assert_eq!(camel_file_path, base.to_string_lossy());
3819 assert_eq!(camel_file_parent, base.to_string_lossy());
3820 assert!(camel_file_absolute_path.ends_with("test.txt"));
3821 assert!(camel_file_canonical_path.ends_with("test.txt"));
3822 }
3823
3824 #[test]
3825 fn test_try_rename_requires_temp_prefix() {
3826 let result = FileConfig::from_uri("file:///tmp?fileExist=TryRename");
3827 assert!(result.is_err());
3828 assert!(
3829 result
3830 .unwrap_err()
3831 .to_string()
3832 .contains("TryRename requires tempPrefix")
3833 );
3834 }
3835
3836 #[test]
3837 fn test_try_rename_with_temp_prefix_ok() {
3838 let result = FileConfig::from_uri("file:///tmp?fileExist=TryRename&tempPrefix=tmp_");
3839 assert!(result.is_ok());
3840 assert_eq!(result.unwrap().file_exist, FileExistStrategy::TryRename);
3841 }
3842
3843 #[tokio::test]
3844 async fn test_try_rename_producer_writes_file() {
3845 use tower::ServiceExt;
3846
3847 let dir = tempfile::tempdir().unwrap();
3848 let base = dir.path();
3849
3850 let component = FileComponent::new();
3851 let ctx = NoOpComponentContext;
3852 let endpoint = component
3853 .create_endpoint(
3854 &format!(
3855 "file://{}?fileExist=TryRename&tempPrefix=tmp_",
3856 base.display()
3857 ),
3858 &ctx,
3859 )
3860 .unwrap();
3861 let ctx = test_producer_ctx();
3862 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3863
3864 let mut exchange = Exchange::new(Message::new("hello"));
3865 exchange.input.set_header(
3866 "CamelFileName",
3867 serde_json::Value::String("output.txt".to_string()),
3868 );
3869
3870 producer.oneshot(exchange).await.unwrap();
3871
3872 let target = base.join("output.txt");
3873 assert!(target.exists(), "output file should exist");
3874 let content = std::fs::read_to_string(&target).unwrap();
3875 assert_eq!(content, "hello");
3876
3877 let tmp_files: Vec<_> = std::fs::read_dir(base)
3878 .unwrap()
3879 .filter_map(|e| e.ok())
3880 .filter(|e| e.file_name().to_string_lossy().starts_with("tmp_"))
3881 .collect();
3882 assert!(tmp_files.is_empty(), "no temp files should remain");
3883 }
3884
3885 #[tokio::test]
3890 async fn test_pipeline_sort_then_limit() {
3891 let dir = tempfile::tempdir().unwrap();
3892 let base = dir.path();
3893 fs::write(base.join("c.txt"), b"ccc").await.unwrap();
3894 fs::write(base.join("a.txt"), b"a").await.unwrap();
3895 fs::write(base.join("b.txt"), b"bb").await.unwrap();
3896
3897 let config = FileConfig::from_uri(&format!(
3898 "file://{}?sortBy=file:length&maxMessagesPerPoll=2&eagerMaxMessagesPerPoll=false",
3899 base.display()
3900 ))
3901 .unwrap();
3902 let filters = CompiledFilters::compile(&config).unwrap();
3903 let mut seen = HashSet::new();
3904 let mut candidates = scan_candidates(&config, &filters, base, &mut seen)
3905 .await
3906 .unwrap()
3907 .candidates;
3908 apply_sort_and_limit(&mut candidates, &config);
3909 assert_eq!(candidates.len(), 2);
3910 assert_eq!(
3911 candidates[0].path.file_name().unwrap().to_str().unwrap(),
3912 "a.txt"
3913 );
3914 assert_eq!(
3915 candidates[1].path.file_name().unwrap().to_str().unwrap(),
3916 "b.txt"
3917 );
3918 }
3919
3920 #[tokio::test]
3921 async fn test_sort_reverse_ignore_case() {
3922 let dir = tempfile::tempdir().unwrap();
3923 let base = dir.path();
3924 fs::write(base.join("Alpha.txt"), b"a").await.unwrap();
3925 fs::write(base.join("beta.txt"), b"b").await.unwrap();
3926
3927 let config = FileConfig::from_uri(&format!(
3928 "file://{}?sortBy=reverse:ignoreCase:file:name",
3929 base.display()
3930 ))
3931 .unwrap();
3932 let filters = CompiledFilters::compile(&config).unwrap();
3933 let mut seen = HashSet::new();
3934 let mut candidates = scan_candidates(&config, &filters, base, &mut seen)
3935 .await
3936 .unwrap()
3937 .candidates;
3938 apply_sort_and_limit(&mut candidates, &config);
3939 assert_eq!(
3940 candidates[0].path.file_name().unwrap().to_str().unwrap(),
3941 "beta.txt"
3942 );
3943 }
3944
3945 #[tokio::test]
3946 async fn test_done_file_noop_no_deletion() {
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("done"), b"").await.unwrap();
3951
3952 let config = FileConfig::from_uri(&format!(
3953 "file://{}?doneFileName=done&noop=true",
3954 base.display()
3955 ))
3956 .unwrap();
3957 let filters = CompiledFilters::compile(&config).unwrap();
3958 let mut seen = HashSet::new();
3959 let candidates = scan_candidates(&config, &filters, base, &mut seen)
3960 .await
3961 .unwrap()
3962 .candidates;
3963 assert_eq!(candidates.len(), 1);
3964 assert!(base.join("done").exists());
3965 }
3966
3967 #[tokio::test]
3968 async fn test_static_done_file_deleted_after_all_processed() {
3969 let dir = tempfile::tempdir().unwrap();
3970 let base = dir.path();
3971 fs::write(base.join("data.txt"), b"d").await.unwrap();
3972 fs::write(base.join("ready"), b"").await.unwrap();
3973
3974 let config = FileConfig::from_uri(&format!(
3975 "file://{}?doneFileName=ready&delete=true",
3976 base.display()
3977 ))
3978 .unwrap();
3979 let filters = CompiledFilters::compile(&config).unwrap();
3980 let mut seen = HashSet::new();
3981 let in_process_locks = std::sync::Arc::new(DashMap::new());
3982 let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
3983 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
3984 let token = CancellationToken::new();
3985 let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
3986
3987 poll_directory(
3988 &config,
3989 &ctx,
3990 &rt(),
3991 &filters,
3992 &mut seen,
3993 &in_process_locks,
3994 &idempotent_repo,
3995 )
3996 .await
3997 .unwrap();
3998
3999 assert!(rx.try_recv().is_ok());
4000 assert!(!base.join("ready").exists());
4001 }
4002
4003 #[tokio::test]
4004 async fn test_static_done_file_not_deleted_when_limited() {
4005 let dir = tempfile::tempdir().unwrap();
4006 let base = dir.path();
4007 fs::write(base.join("a.txt"), b"a").await.unwrap();
4008 fs::write(base.join("b.txt"), b"b").await.unwrap();
4009 fs::write(base.join("ready"), b"").await.unwrap();
4010
4011 let config = FileConfig::from_uri(&format!(
4012 "file://{}?doneFileName=ready&delete=true&maxMessagesPerPoll=1&eagerMaxMessagesPerPoll=true",
4013 base.display()
4014 ))
4015 .unwrap();
4016 let filters = CompiledFilters::compile(&config).unwrap();
4017 let mut seen = HashSet::new();
4018 let in_process_locks = std::sync::Arc::new(DashMap::new());
4019 let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
4020 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
4021 let token = CancellationToken::new();
4022 let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
4023
4024 poll_directory(
4025 &config,
4026 &ctx,
4027 &rt(),
4028 &filters,
4029 &mut seen,
4030 &in_process_locks,
4031 &idempotent_repo,
4032 )
4033 .await
4034 .unwrap();
4035
4036 let _ = rx.try_recv();
4037 assert!(base.join("ready").exists());
4038 }
4039
4040 #[tokio::test]
4041 async fn test_dynamic_done_file_deleted_per_file() {
4042 let dir = tempfile::tempdir().unwrap();
4043 let base = dir.path();
4044 fs::write(base.join("data.txt"), b"d").await.unwrap();
4045 fs::write(base.join("data.txt.done"), b"").await.unwrap();
4046
4047 let config = FileConfig::from_uri(&format!(
4048 "file://{}?doneFileName=${{file:name}}.done&delete=true",
4049 base.display()
4050 ))
4051 .unwrap();
4052 let filters = CompiledFilters::compile(&config).unwrap();
4053 let mut seen = HashSet::new();
4054 let in_process_locks = std::sync::Arc::new(DashMap::new());
4055 let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
4056 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
4057 let token = CancellationToken::new();
4058 let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
4059
4060 poll_directory(
4061 &config,
4062 &ctx,
4063 &rt(),
4064 &filters,
4065 &mut seen,
4066 &in_process_locks,
4067 &idempotent_repo,
4068 )
4069 .await
4070 .unwrap();
4071
4072 assert!(rx.try_recv().is_ok());
4073 assert!(!base.join("data.txt.done").exists());
4074 }
4075
4076 #[tokio::test]
4083 async fn test_done_file_rejects_absolute_file_name_header() {
4084 use tower::ServiceExt;
4085
4086 let dir = tempfile::tempdir().unwrap();
4087 let dir_path = dir.path().to_str().unwrap();
4088 let outside = dir.path().parent().unwrap().join("pwn.done");
4089
4090 let component = FileComponent::new();
4091 let ctx = NoOpComponentContext;
4092 let endpoint = component
4093 .create_endpoint(
4094 &format!("file:{dir_path}?doneFileName=${{file:name}}.done"),
4095 &ctx,
4096 )
4097 .unwrap();
4098 let ctx = test_producer_ctx();
4099 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4100
4101 let mut exchange = Exchange::new(Message::new("data"));
4102 exchange.input.set_header(
4103 "CamelFileName",
4104 serde_json::Value::String("/tmp/pwn".to_string()),
4105 );
4106
4107 let result = producer.oneshot(exchange).await;
4108 assert!(result.is_err(), "absolute CamelFileName must be rejected");
4109 assert!(
4110 !outside.exists() && !std::path::Path::new("/tmp/pwn.done").exists(),
4111 "no done file may be created outside the base directory"
4112 );
4113 }
4114
4115 #[tokio::test]
4117 async fn test_done_file_rejects_traversal_in_file_name_header() {
4118 use tower::ServiceExt;
4119
4120 let dir = tempfile::tempdir().unwrap();
4121 let dir_path = dir.path().to_str().unwrap();
4122
4123 let component = FileComponent::new();
4124 let ctx = NoOpComponentContext;
4125 let endpoint = component
4126 .create_endpoint(
4127 &format!("file:{dir_path}?doneFileName=${{file:name}}.done"),
4128 &ctx,
4129 )
4130 .unwrap();
4131 let ctx = test_producer_ctx();
4132 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4133
4134 let mut exchange = Exchange::new(Message::new("data"));
4135 exchange.input.set_header(
4136 "CamelFileName",
4137 serde_json::Value::String("../escape".to_string()),
4138 );
4139
4140 let result = producer.oneshot(exchange).await;
4141 assert!(
4142 result.is_err(),
4143 "traversal in CamelFileName must be rejected"
4144 );
4145 assert!(
4146 !dir.path().parent().unwrap().join("escape.done").exists(),
4147 "no done file may be created outside the base directory"
4148 );
4149 }
4150
4151 #[cfg(unix)]
4154 #[tokio::test]
4155 async fn test_append_refuses_dangling_symlink_leaf() {
4156 use tower::ServiceExt;
4157
4158 let dir = tempfile::tempdir().unwrap();
4159 let dir_path = dir.path().to_str().unwrap();
4160 let outside = dir.path().parent().unwrap().join("escape_target");
4161
4162 std::os::unix::fs::symlink(&outside, dir.path().join("link")).unwrap();
4164
4165 let component = FileComponent::new();
4166 let ctx = NoOpComponentContext;
4167 let endpoint = component
4168 .create_endpoint(&format!("file:{dir_path}?fileExist=Append"), &ctx)
4169 .unwrap();
4170 let ctx = test_producer_ctx();
4171 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4172
4173 let mut exchange = Exchange::new(Message::new("attacker bytes"));
4174 exchange.input.set_header(
4175 "CamelFileName",
4176 serde_json::Value::String("link".to_string()),
4177 );
4178
4179 let result = producer.oneshot(exchange).await;
4180 assert!(
4181 result.is_err(),
4182 "Append must refuse to open a symlink leaf (O_NOFOLLOW)"
4183 );
4184 assert!(
4185 !outside.exists(),
4186 "no file may be created outside the base via dangling symlink"
4187 );
4188 }
4189
4190 #[cfg(unix)]
4193 #[tokio::test]
4194 async fn test_fail_refuses_symlink_leaf() {
4195 use tower::ServiceExt;
4196
4197 let dir = tempfile::tempdir().unwrap();
4198 let dir_path = dir.path().to_str().unwrap();
4199 let outside = dir.path().parent().unwrap().join("fail_escape_target");
4200 std::fs::write(&outside, b"precious").unwrap();
4201
4202 std::os::unix::fs::symlink(&outside, dir.path().join("link")).unwrap();
4204
4205 let component = FileComponent::new();
4206 let ctx = NoOpComponentContext;
4207 let endpoint = component
4208 .create_endpoint(&format!("file:{dir_path}?fileExist=Fail"), &ctx)
4209 .unwrap();
4210 let ctx = test_producer_ctx();
4211 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4212
4213 let mut exchange = Exchange::new(Message::new("clobber"));
4214 exchange.input.set_header(
4215 "CamelFileName",
4216 serde_json::Value::String("link".to_string()),
4217 );
4218
4219 let result = producer.oneshot(exchange).await;
4220 assert!(result.is_err(), "Fail must refuse a symlink leaf");
4221 assert_eq!(
4222 std::fs::read(&outside).unwrap(),
4223 b"precious",
4224 "outside file must remain untouched"
4225 );
4226 }
4227
4228 #[test]
4230 fn test_validate_relative_filename_rejects_evil_values() {
4231 assert!(validate_relative_filename("/etc/passwd", "fileName").is_err());
4232 assert!(validate_relative_filename("../x", "fileName").is_err());
4233 assert!(validate_relative_filename("a/../../x", "fileName").is_err());
4234 assert!(validate_relative_filename("a\0b", "fileName").is_err());
4235 assert!(validate_relative_filename("", "fileName").is_err());
4236 assert!(validate_relative_filename("ok/nested.txt", "fileName").is_ok());
4237 }
4238
4239 #[cfg(unix)]
4247 #[test]
4248 fn validator_rejects_symlinked_ancestor() {
4249 let base = tempfile::tempdir().unwrap();
4250 let outside = tempfile::tempdir().unwrap();
4251 std::os::unix::fs::symlink(outside.path(), base.path().join("link")).unwrap();
4252
4253 let target = base.path().join("link").join("new").join("f.txt");
4254 let err = validate_path_is_within_base(base.path(), &target)
4255 .unwrap_err()
4256 .to_string();
4257 assert!(
4258 err.contains("symlinked component"),
4259 "error must name the symlinked component, got: {err}"
4260 );
4261 assert!(
4262 err.contains(&base.path().join("link").display().to_string()),
4263 "error must name the symlinked component 'link', got: {err}"
4264 );
4265 }
4266
4267 #[cfg(unix)]
4271 #[test]
4272 fn validator_rejects_in_base_alias() {
4273 let base = tempfile::tempdir().unwrap();
4274 std::fs::create_dir(base.path().join("real")).unwrap();
4275 std::os::unix::fs::symlink(base.path().join("real"), base.path().join("alias")).unwrap();
4276
4277 let target = base.path().join("alias").join("new").join("f.txt");
4278 let err = validate_path_is_within_base(base.path(), &target)
4279 .unwrap_err()
4280 .to_string();
4281 assert!(
4282 err.contains("symlinked component"),
4283 "in-base alias must be rejected, got: {err}"
4284 );
4285 assert!(
4286 err.contains(&base.path().join("alias").display().to_string()),
4287 "error must name the symlinked component 'alias', got: {err}"
4288 );
4289 }
4290
4291 #[test]
4294 fn validator_accepts_missing_deep_path() {
4295 let base = tempfile::tempdir().unwrap();
4296 let target = base.path().join("a").join("b").join("c.txt");
4297 validate_path_is_within_base(base.path(), &target).unwrap();
4298 }
4299
4300 #[cfg(unix)]
4303 #[test]
4304 fn validator_accepts_symlinked_base() {
4305 let parent = tempfile::tempdir().unwrap();
4306 std::fs::create_dir(parent.path().join("real")).unwrap();
4307 std::os::unix::fs::symlink(parent.path().join("real"), parent.path().join("base")).unwrap();
4308
4309 let base = parent.path().join("base");
4310 let target = base.join("x.txt");
4311 validate_path_is_within_base(&base, &target).unwrap();
4312 }
4313
4314 #[cfg(unix)]
4318 #[test]
4319 fn validator_rejects_existing_outside_target() {
4320 let base = tempfile::tempdir().unwrap();
4321 let outside = tempfile::tempdir().unwrap();
4322 std::fs::write(outside.path().join("existing.txt"), b"x").unwrap();
4323 std::os::unix::fs::symlink(outside.path(), base.path().join("link")).unwrap();
4324
4325 let target = base.path().join("link").join("existing.txt");
4326 let err = validate_path_is_within_base(base.path(), &target)
4327 .unwrap_err()
4328 .to_string();
4329 assert!(
4330 err.contains("symlinked component"),
4331 "chain scan must fire before containment, got: {err}"
4332 );
4333 }
4334
4335 #[cfg(unix)]
4346 mod producer_symlink_confinement {
4347 use super::*;
4348 use tower::ServiceExt;
4349
4350 struct SymlinkFixture {
4354 base: tempfile::TempDir,
4355 outside: tempfile::TempDir,
4356 }
4357
4358 impl SymlinkFixture {
4359 fn new() -> Self {
4360 Self::build(true)
4361 }
4362
4363 fn new_without_link() -> Self {
4366 Self::build(false)
4367 }
4368
4369 fn build(with_link: bool) -> Self {
4370 let base = tempfile::tempdir().unwrap();
4371 let outside = tempfile::tempdir().unwrap();
4372 if with_link {
4373 std::os::unix::fs::symlink(outside.path(), base.path().join("link")).unwrap();
4374 }
4375 Self { base, outside }
4376 }
4377
4378 fn base(&self) -> &std::path::Path {
4379 self.base.path()
4380 }
4381
4382 fn outside(&self) -> &std::path::Path {
4383 self.outside.path()
4384 }
4385
4386 fn producer(&self, query: &str) -> BoxProcessor {
4389 make_producer(self.base(), query)
4390 }
4391
4392 fn assert_outside_untouched(&self) {
4394 let entries: Vec<_> = std::fs::read_dir(self.outside())
4395 .unwrap()
4396 .collect::<Result<_, _>>()
4397 .unwrap();
4398 assert!(
4399 entries.is_empty(),
4400 "outside/ must contain no artifacts, got: {:?}",
4401 entries.iter().map(|e| e.path()).collect::<Vec<_>>()
4402 );
4403 }
4404 }
4405
4406 fn make_producer(dir_path: &std::path::Path, query: &str) -> BoxProcessor {
4409 let component = FileComponent::new();
4410 let ctx = NoOpComponentContext;
4411 let endpoint = component
4412 .create_endpoint(&format!("file:{}{query}", dir_path.display()), &ctx)
4413 .unwrap();
4414 let ctx = test_producer_ctx();
4415 endpoint.create_producer(rt(), &ctx).unwrap()
4416 }
4417
4418 async fn call(
4421 producer: BoxProcessor,
4422 file_name: &str,
4423 body: &str,
4424 ) -> Result<Exchange, CamelError> {
4425 let mut exchange = Exchange::new(Message::new(body));
4426 exchange
4427 .input
4428 .set_header("CamelFileName", serde_json::Value::String(file_name.into()));
4429 producer.oneshot(exchange).await
4430 }
4431
4432 #[tokio::test]
4436 async fn producer_rejects_symlinked_ancestor_all_strategies() {
4437 for strategy in ["Fail", "Append", "Override", "TryRename"] {
4438 let fixture = SymlinkFixture::new();
4439 let query = if strategy == "TryRename" {
4440 format!("?fileExist={strategy}&tempPrefix=tmp-")
4441 } else {
4442 format!("?fileExist={strategy}")
4443 };
4444 let producer = fixture.producer(&query);
4445
4446 let Err(err) = call(producer, "link/new/file.txt", "payload").await else {
4447 panic!("fileExist={strategy}: symlinked ancestor must be rejected");
4448 };
4449 let msg = err.to_string();
4450 assert!(
4451 msg.contains("symlinked component"),
4452 "fileExist={strategy}: error must mention the confinement violation, got: {msg}"
4453 );
4454 assert!(
4455 !fixture.outside().join("new").exists(),
4456 "fileExist={strategy}: outside/new/ must not exist"
4457 );
4458 fixture.assert_outside_untouched();
4459 }
4460 }
4461
4462 #[tokio::test]
4466 async fn producer_done_file_rejects_symlinked_ancestor() {
4467 let fixture = SymlinkFixture::new();
4468 let producer = fixture.producer("?doneFileName=link/done-marker");
4469
4470 let Err(err) = call(producer, "body.txt", "payload").await else {
4471 panic!("symlinked doneFileName ancestor must be rejected");
4472 };
4473 let msg = err.to_string();
4474 assert!(
4475 msg.contains("symlinked component"),
4476 "error must mention the confinement violation, got: {msg}"
4477 );
4478 assert!(
4482 fixture.base().join("body.txt").exists(),
4483 "base/body.txt must exist: body write precedes done-file validation"
4484 );
4485 assert!(
4486 !fixture.outside().join("done-marker").exists(),
4487 "outside/done-marker must not exist"
4488 );
4489 fixture.assert_outside_untouched();
4490 }
4491
4492 #[tokio::test]
4496 async fn producer_ignore_probe_rejects_symlinked_ancestor() {
4497 let fixture = SymlinkFixture::new();
4498 std::fs::write(fixture.outside().join("existing.txt"), b"original").unwrap();
4499
4500 let producer = fixture.producer("?fileExist=Ignore");
4501 let Err(err) = call(producer, "link/existing.txt", "probe").await else {
4502 panic!("Ignore probe via symlinked ancestor must be rejected");
4503 };
4504 let msg = err.to_string();
4505 assert!(
4506 msg.contains("symlinked component"),
4507 "error must mention the confinement violation, got: {msg}"
4508 );
4509
4510 let content = std::fs::read(fixture.outside().join("existing.txt")).unwrap();
4511 assert_eq!(
4512 content, b"original",
4513 "outside/existing.txt content must be unchanged"
4514 );
4515 }
4516
4517 #[tokio::test]
4521 async fn producer_in_base_alias_rejected() {
4522 let fixture = SymlinkFixture::new();
4523 std::fs::create_dir(fixture.base().join("real")).unwrap();
4524 std::os::unix::fs::symlink(fixture.base().join("real"), fixture.base().join("alias"))
4525 .unwrap();
4526
4527 let producer = fixture.producer(""); let Err(err) = call(producer, "alias/f.txt", "payload").await else {
4529 panic!("in-base alias must be rejected");
4530 };
4531 let msg = err.to_string();
4532 assert!(
4533 msg.contains("symlinked component") && msg.contains("alias"),
4534 "error must name the symlinked component 'alias', got: {msg}"
4535 );
4536 }
4537
4538 #[tokio::test]
4541 async fn producer_nested_new_dirs_still_succeed() {
4542 let fixture = SymlinkFixture::new_without_link();
4543 let producer = fixture.producer("?autoCreate=true");
4544
4545 call(producer, "a/b/c.txt", "nested body")
4546 .await
4547 .expect("nested auto-created directories must succeed");
4548
4549 let content = std::fs::read_to_string(fixture.base().join("a/b/c.txt")).unwrap();
4550 assert_eq!(content, "nested body");
4551 }
4552
4553 #[tokio::test]
4557 async fn producer_symlinked_base_still_accepted() {
4558 let parent = tempfile::tempdir().unwrap();
4559 std::fs::create_dir(parent.path().join("real")).unwrap();
4560 std::os::unix::fs::symlink(parent.path().join("real"), parent.path().join("base"))
4561 .unwrap();
4562
4563 let producer = make_producer(&parent.path().join("base"), "");
4564 call(producer, "x.txt", "payload")
4565 .await
4566 .expect("a symlinked base directory must be accepted");
4567
4568 let content =
4569 std::fs::read_to_string(parent.path().join("real").join("x.txt")).unwrap();
4570 assert_eq!(
4571 content, "payload",
4572 "file must be written under the real dir"
4573 );
4574 }
4575 }
4576
4577 #[test]
4578 fn uri_options_count_parity() {
4579 assert_eq!(
4580 FileConfig::uri_options().len(),
4581 32,
4582 "FileUriConfig #[uri_param] count drifted from parser"
4583 );
4584 }
4585
4586 struct RecordingMetrics {
4592 errors: Arc<std::sync::Mutex<Vec<(String, String)>>>,
4593 }
4594
4595 impl camel_api::MetricsCollector for RecordingMetrics {
4596 fn record_exchange_duration(&self, _: &str, _: Duration) {}
4597 fn increment_errors(&self, route_id: &str, error_type: &str) {
4598 self.errors
4599 .lock()
4600 .unwrap()
4601 .push((route_id.to_string(), error_type.to_string()));
4602 }
4603 fn increment_exchanges(&self, _: &str) {}
4604 fn set_queue_depth(&self, _: &str, _: usize) {}
4605 fn record_circuit_breaker_change(&self, _: &str, _: &str, _: &str) {}
4606 }
4607
4608 struct RecordingRuntime {
4609 metrics_collector: Arc<RecordingMetrics>,
4610 }
4611
4612 impl RecordingRuntime {
4613 fn new(errors: Arc<std::sync::Mutex<Vec<(String, String)>>>) -> Self {
4614 Self {
4615 metrics_collector: Arc::new(RecordingMetrics { errors }),
4616 }
4617 }
4618 }
4619
4620 impl camel_component_api::RuntimeObservability for RecordingRuntime {
4621 fn metrics(&self) -> Arc<dyn camel_api::MetricsCollector> {
4622 self.metrics_collector.clone() as Arc<dyn camel_api::MetricsCollector>
4623 }
4624 fn health(&self) -> Arc<dyn camel_component_api::HealthCheckRegistry> {
4625 panic!("RecordingRuntime::health not used in this test")
4626 }
4627 }
4628
4629 #[tokio::test]
4630 async fn file_poll_send_failure_counts_b_prime() {
4631 let dir = tempfile::tempdir().unwrap();
4635 let base = dir.path();
4636 std::fs::write(base.join("data.txt"), b"payload").unwrap();
4637
4638 let config = FileConfig::from_uri(&format!("file:{}", base.display())).unwrap();
4639 let filters = CompiledFilters::compile(&config).unwrap();
4640 let mut seen = HashSet::new();
4641 let in_process_locks = std::sync::Arc::new(DashMap::new());
4642 let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
4643
4644 let (tx, rx) = tokio::sync::mpsc::channel(16);
4645 drop(rx); let token = CancellationToken::new();
4647 let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
4648
4649 let errors = Arc::new(std::sync::Mutex::new(Vec::new()));
4650 let runtime: Arc<dyn camel_component_api::RuntimeObservability> =
4651 Arc::new(RecordingRuntime::new(Arc::clone(&errors)));
4652
4653 let result = poll_directory(
4654 &config,
4655 &ctx,
4656 &runtime,
4657 &filters,
4658 &mut seen,
4659 &in_process_locks,
4660 &idempotent_repo,
4661 )
4662 .await;
4663
4664 assert!(matches!(result, Err(CamelError::ChannelClosed)));
4665 let recorded = errors.lock().unwrap().clone();
4666 assert_eq!(
4667 recorded,
4668 vec![(
4669 "file-test-route".to_string(),
4670 "b-prime:file:poll-send".to_string()
4671 )]
4672 );
4673
4674 let gone = tempfile::tempdir().unwrap();
4677 let gone_path = gone.path().to_path_buf();
4678 let scan_config = FileConfig::from_uri(&format!("file:{}", gone_path.display())).unwrap();
4679 let scan_filters = CompiledFilters::compile(&scan_config).unwrap();
4680 drop(gone); let mut scan_seen = HashSet::new();
4683 let scan_errors = Arc::new(std::sync::Mutex::new(Vec::new()));
4684 let scan_runtime: Arc<dyn camel_component_api::RuntimeObservability> =
4685 Arc::new(RecordingRuntime::new(Arc::clone(&scan_errors)));
4686 let (scan_tx, mut scan_rx) = tokio::sync::mpsc::channel(16);
4687 let scan_token = CancellationToken::new();
4688 let scan_ctx = ConsumerContext::new(scan_tx, scan_token, "file-test-route".to_string());
4689
4690 let scan_result = poll_directory(
4691 &scan_config,
4692 &scan_ctx,
4693 &scan_runtime,
4694 &scan_filters,
4695 &mut scan_seen,
4696 &in_process_locks,
4697 &idempotent_repo,
4698 )
4699 .await;
4700
4701 assert!(scan_result.is_err());
4702 assert!(
4703 scan_errors.lock().unwrap().is_empty(),
4704 "scan failure must not emit b-prime:file:poll-send"
4705 );
4706 assert!(scan_rx.try_recv().is_err());
4707 }
4708}