1pub mod bundle;
8pub mod health;
9mod poll_logic;
10mod polling_consumer;
11
12use poll_logic::poll_directory;
13
14pub use bundle::FileBundle;
15pub use health::FileHealthCheck;
16
17use std::collections::HashSet;
18use std::future::Future;
19use std::path::PathBuf;
20use std::pin::Pin;
21use std::str::FromStr;
22use std::sync::Arc;
23use std::task::{Context, Poll};
24use std::time::Duration;
25
26use async_trait::async_trait;
27use dashmap::DashMap;
28use regex::Regex;
29use tokio::fs;
30use tokio::fs::OpenOptions;
31use tokio::io;
32use tokio::io::AsyncWriteExt;
33use tokio::time;
34use tower::Service;
35use tracing::{debug, warn};
36
37use camel_component_api::{Body, BoxProcessor, CamelError, Exchange};
38use camel_component_api::{
39 Component, Consumer, ConsumerContext, Endpoint, PollingConsumer, ProducerContext,
40};
41use camel_component_api::{UriConfig, parse_uri};
42use camel_language_api::Language;
43use camel_language_simple::SimpleLanguage;
44
45struct TempFileGuard {
54 path: PathBuf,
55 disarm: bool,
56}
57
58impl TempFileGuard {
59 fn new(path: PathBuf) -> Self {
60 Self {
61 path,
62 disarm: false,
63 }
64 }
65
66 fn disarm(&mut self) {
68 self.disarm = true;
69 }
70}
71
72impl Drop for TempFileGuard {
73 fn drop(&mut self) {
74 if !self.disarm {
75 let _ = std::fs::remove_file(&self.path);
77 }
78 }
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
87pub enum FileExistStrategy {
88 #[default]
90 Override,
91 Append,
93 Fail,
95 Ignore,
97}
98
99impl FromStr for FileExistStrategy {
100 type Err = String;
101
102 fn from_str(s: &str) -> Result<Self, Self::Err> {
103 match s {
104 "Override" | "override" => Ok(FileExistStrategy::Override),
105 "Append" | "append" => Ok(FileExistStrategy::Append),
106 "Fail" | "fail" => Ok(FileExistStrategy::Fail),
107 "Ignore" | "ignore" => Ok(FileExistStrategy::Ignore),
108 other => Err(format!("unknown FileExistStrategy: {other}")),
109 }
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
114pub enum ReadLockStrategy {
115 #[default]
116 None,
117 InProcess,
118 Rename,
119}
120
121impl FromStr for ReadLockStrategy {
122 type Err = String;
123
124 fn from_str(s: &str) -> Result<Self, Self::Err> {
125 match s {
126 "None" | "none" => Ok(Self::None),
127 "InProcess" | "inProcess" | "inprocess" => Ok(Self::InProcess),
128 "Rename" | "rename" => Ok(Self::Rename),
129 other => Err(format!("unknown ReadLockStrategy: {other}")),
130 }
131 }
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
135pub enum IdempotentKey {
136 #[default]
137 None,
138 FileName,
139 FilePath,
140 FileSize,
141 Digest,
143}
144
145impl FromStr for IdempotentKey {
146 type Err = String;
147
148 fn from_str(s: &str) -> Result<Self, Self::Err> {
149 match s {
150 "None" | "none" => Ok(Self::None),
151 "FileName" | "fileName" | "filename" => Ok(Self::FileName),
152 "FilePath" | "filePath" | "filepath" => Ok(Self::FilePath),
153 "FileSize" | "fileSize" | "filesize" => Ok(Self::FileSize),
154 "Digest" | "digest" => Ok(Self::Digest),
155 other => Err(format!("unknown IdempotentKey: {other}")),
156 }
157 }
158}
159
160#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
168#[serde(default)]
169pub struct FileGlobalConfig {
170 pub delay_ms: u64,
171 pub initial_delay_ms: u64,
172 pub read_timeout_ms: u64,
173 pub write_timeout_ms: u64,
174}
175
176impl Default for FileGlobalConfig {
177 fn default() -> Self {
178 Self {
179 delay_ms: 500,
180 initial_delay_ms: 1_000,
181 read_timeout_ms: 30_000,
182 write_timeout_ms: 30_000,
183 }
184 }
185}
186
187impl FileGlobalConfig {
188 pub fn new() -> Self {
189 Self::default()
190 }
191 pub fn with_delay_ms(mut self, v: u64) -> Self {
192 self.delay_ms = v;
193 self
194 }
195 pub fn with_initial_delay_ms(mut self, v: u64) -> Self {
196 self.initial_delay_ms = v;
197 self
198 }
199 pub fn with_read_timeout_ms(mut self, v: u64) -> Self {
200 self.read_timeout_ms = v;
201 self
202 }
203 pub fn with_write_timeout_ms(mut self, v: u64) -> Self {
204 self.write_timeout_ms = v;
205 self
206 }
207}
208
209#[derive(Debug, Clone)]
237pub struct FileConfig {
238 pub directory: String,
240
241 #[allow(dead_code)]
243 delay_ms: u64,
244
245 pub delay: Duration,
247
248 #[allow(dead_code)]
250 initial_delay_ms: u64,
251
252 pub initial_delay: Duration,
254
255 pub noop: bool,
257
258 pub delete: bool,
260
261 move_to: Option<String>,
264
265 pub file_name: Option<String>,
267
268 pub include: Option<String>,
270
271 pub exclude: Option<String>,
273
274 pub recursive: bool,
276
277 pub file_exist: FileExistStrategy,
279
280 pub read_lock_strategy: ReadLockStrategy,
282
283 pub idempotent_key: IdempotentKey,
285
286 pub done_file_name: Option<String>,
288
289 pub charset: Option<String>,
291
292 pub temp_prefix: Option<String>,
294
295 pub auto_create: bool,
297
298 pub starting_directory_must_exist: bool,
300
301 #[allow(dead_code)]
303 read_timeout_ms: u64,
304
305 pub read_timeout: Duration,
307
308 #[allow(dead_code)]
310 write_timeout_ms: u64,
311
312 pub write_timeout: Duration,
314}
315
316impl UriConfig for FileConfig {
317 fn scheme() -> &'static str {
318 "file"
319 }
320
321 fn from_uri(uri: &str) -> Result<Self, CamelError> {
322 let parts = parse_uri(uri)?;
323 Self::from_components(parts)
324 }
325
326 fn from_components(parts: camel_component_api::UriComponents) -> Result<Self, CamelError> {
327 if parts.scheme != Self::scheme() {
328 return Err(CamelError::InvalidUri(format!(
329 "unsupported scheme '{}', expected '{}'",
330 parts.scheme,
331 Self::scheme()
332 )));
333 }
334
335 fn parse_bool_param(
336 params: &std::collections::HashMap<String, String>,
337 key: &str,
338 default: bool,
339 ) -> Result<bool, CamelError> {
340 match params.get(key) {
341 Some(v) => match v.to_lowercase().as_str() {
342 "true" | "1" | "yes" => Ok(true),
343 "false" | "0" | "no" => Ok(false),
344 _ => Err(CamelError::InvalidUri(format!(
345 "invalid value for {key}: invalid boolean value: '{v}'"
346 ))),
347 },
348 None => Ok(default),
349 }
350 }
351
352 fn parse_u64_param(
353 params: &std::collections::HashMap<String, String>,
354 key: &str,
355 default: u64,
356 ) -> Result<u64, CamelError> {
357 match params.get(key) {
358 Some(v) => v
359 .parse::<u64>()
360 .map_err(|e| CamelError::InvalidUri(format!("invalid value for {key}: {e}"))),
361 None => Ok(default),
362 }
363 }
364
365 fn parse_enum_param<T: FromStr>(
366 params: &std::collections::HashMap<String, String>,
367 key: &str,
368 default: &str,
369 ) -> Result<T, CamelError>
370 where
371 T::Err: std::fmt::Display,
372 {
373 let raw = params.get(key).map(String::as_str).unwrap_or(default);
374 raw.parse::<T>().map_err(|e| {
375 CamelError::InvalidUri(format!("invalid value for parameter '{key}': {e}"))
376 })
377 }
378
379 let params = &parts.params;
380 let delay_ms = parse_u64_param(params, "delay", 500)?;
381 let initial_delay_ms = parse_u64_param(params, "initialDelay", 1000)?;
382 let read_timeout_ms = parse_u64_param(params, "readTimeout", 30_000)?;
383 let write_timeout_ms = parse_u64_param(params, "writeTimeout", 30_000)?;
384
385 let cfg = Self {
386 directory: parts.path,
387 delay_ms,
388 delay: Duration::from_millis(delay_ms),
389 initial_delay_ms,
390 initial_delay: Duration::from_millis(initial_delay_ms),
391 noop: parse_bool_param(params, "noop", false)?,
392 delete: parse_bool_param(params, "delete", false)?,
393 move_to: params.get("move").cloned(),
394 file_name: params.get("fileName").cloned(),
395 include: params.get("include").cloned(),
396 exclude: params.get("exclude").cloned(),
397 recursive: parse_bool_param(params, "recursive", false)?,
398 file_exist: parse_enum_param(params, "fileExist", "Override")?,
399 read_lock_strategy: parse_enum_param(params, "readLock", "None")?,
400 idempotent_key: parse_enum_param(params, "idempotentKey", "None")?,
401 done_file_name: params.get("doneFileName").cloned(),
402 charset: params.get("charset").cloned(),
403 temp_prefix: params.get("tempPrefix").cloned(),
404 auto_create: parse_bool_param(params, "autoCreate", true)?,
405 starting_directory_must_exist: parse_bool_param(
406 params,
407 "startingDirectoryMustExist",
408 false,
409 )?,
410 read_timeout_ms,
411 read_timeout: Duration::from_millis(read_timeout_ms),
412 write_timeout_ms,
413 write_timeout: Duration::from_millis(write_timeout_ms),
414 };
415
416 cfg.validate()
417 }
418
419 fn validate(self) -> Result<Self, CamelError> {
420 if let Some(ref move_to) = self.move_to
422 && path_contains_traversal(move_to)
423 {
424 return Err(CamelError::Config(format!(
425 "move_to contains path traversal component: {move_to}"
426 )));
427 }
428
429 if let Some(ref move_to) = self.move_to
430 && std::path::Path::new(move_to).is_absolute()
431 {
432 return Err(CamelError::InvalidUri(format!(
433 "move_to must be relative path within base directory: {move_to}"
434 )));
435 }
436
437 if let Some(ref temp_prefix) = self.temp_prefix
439 && path_contains_traversal(temp_prefix)
440 {
441 return Err(CamelError::Config(format!(
442 "temp_prefix contains path traversal component: {temp_prefix}"
443 )));
444 }
445
446 if let Some(ref temp_prefix) = self.temp_prefix
447 && !is_valid_temp_prefix(temp_prefix)
448 {
449 return Err(CamelError::Config(
450 "temp_prefix must be plain filename prefix (no path separators, absolute paths, or null bytes)".into(),
451 ));
452 }
453
454 if let Some(ref file_name) = self.file_name {
456 if file_name.is_empty() {
457 return Err(CamelError::Config("file_name must not be empty".into()));
458 }
459 if file_name.contains('\0') {
461 return Err(CamelError::Config(
462 "file_name must not contain null bytes".into(),
463 ));
464 }
465 }
466
467 if self.starting_directory_must_exist {
469 let dir_path = std::path::Path::new(&self.directory);
470 if !dir_path.exists() {
471 return Err(CamelError::Config(format!(
472 "starting directory does not exist: {}",
473 self.directory
474 )));
475 }
476 }
477
478 let move_to = if self.noop || self.delete {
482 None
483 } else {
484 Some(self.move_to.unwrap_or_else(|| ".camel".to_string()))
485 };
486
487 Ok(Self { move_to, ..self })
488 }
489}
490
491impl FileConfig {
492 pub fn apply_global_defaults(&mut self, global: &FileGlobalConfig) {
500 if self.delay == Duration::from_millis(500) {
501 self.delay = Duration::from_millis(global.delay_ms);
502 }
503 if self.initial_delay == Duration::from_millis(1_000) {
504 self.initial_delay = Duration::from_millis(global.initial_delay_ms);
505 }
506 if self.read_timeout == Duration::from_millis(30_000) {
507 self.read_timeout = Duration::from_millis(global.read_timeout_ms);
508 }
509 if self.write_timeout == Duration::from_millis(30_000) {
510 self.write_timeout = Duration::from_millis(global.write_timeout_ms);
511 }
512 }
513}
514
515pub struct FileComponent {
520 config: Option<FileGlobalConfig>,
521}
522
523impl FileComponent {
524 pub fn new() -> Self {
525 Self { config: None }
526 }
527
528 pub fn with_config(config: FileGlobalConfig) -> Self {
529 Self {
530 config: Some(config),
531 }
532 }
533
534 pub fn with_optional_config(config: Option<FileGlobalConfig>) -> Self {
535 Self { config }
536 }
537}
538
539impl Default for FileComponent {
540 fn default() -> Self {
541 Self::new()
542 }
543}
544
545impl Component for FileComponent {
546 fn scheme(&self) -> &str {
547 "file"
548 }
549
550 fn create_endpoint(
551 &self,
552 uri: &str,
553 ctx: &dyn camel_component_api::ComponentContext,
554 ) -> Result<Box<dyn Endpoint>, CamelError> {
555 let mut config = FileConfig::from_uri(uri)?;
556 if let Some(ref global_config) = self.config {
557 config.apply_global_defaults(global_config);
558 }
559 let dir_path = std::path::PathBuf::from(&config.directory);
560 let health_check = FileHealthCheck::new(dir_path.clone());
561 ctx.register_current_route_health_check(std::sync::Arc::new(health_check));
562 Ok(Box::new(FileEndpoint {
563 uri: uri.to_string(),
564 config,
565 in_process_locks: std::sync::Arc::new(DashMap::new()),
566 idempotent_repo: std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new())),
567 }))
568 }
569}
570
571struct FileEndpoint {
576 uri: String,
577 config: FileConfig,
578 in_process_locks: std::sync::Arc<DashMap<PathBuf, ()>>,
579 idempotent_repo: std::sync::Arc<tokio::sync::Mutex<HashSet<String>>>,
580}
581
582impl Endpoint for FileEndpoint {
583 fn uri(&self) -> &str {
584 &self.uri
585 }
586
587 fn create_consumer(
588 &self,
589 rt: Arc<dyn camel_component_api::RuntimeObservability>,
590 ) -> Result<Box<dyn Consumer>, CamelError> {
591 Ok(Box::new(FileConsumer::new(
592 self.config.clone(),
593 self.in_process_locks.clone(),
594 self.idempotent_repo.clone(),
595 rt,
596 )))
597 }
598
599 fn create_producer(
600 &self,
601 _rt: Arc<dyn camel_component_api::RuntimeObservability>,
602 _ctx: &ProducerContext,
603 ) -> Result<BoxProcessor, CamelError> {
604 Ok(BoxProcessor::new(FileProducer {
605 config: self.config.clone(),
606 }))
607 }
608
609 fn polling_consumer(&self) -> Option<Box<dyn PollingConsumer>> {
610 Some(Box::new(polling_consumer::FilePollingConsumer::new(
611 self.config.clone(),
612 self.in_process_locks.clone(),
613 self.idempotent_repo.clone(),
614 )))
615 }
616}
617
618struct FileConsumer {
623 config: FileConfig,
624 seen: HashSet<PathBuf>,
625 in_process_locks: std::sync::Arc<DashMap<PathBuf, ()>>,
626 idempotent_repo: std::sync::Arc<tokio::sync::Mutex<HashSet<String>>>,
627 #[allow(dead_code)]
630 runtime: Arc<dyn camel_component_api::RuntimeObservability>,
631}
632
633impl FileConsumer {
634 fn new(
635 config: FileConfig,
636 in_process_locks: std::sync::Arc<DashMap<PathBuf, ()>>,
637 idempotent_repo: std::sync::Arc<tokio::sync::Mutex<HashSet<String>>>,
638 runtime: Arc<dyn camel_component_api::RuntimeObservability>,
639 ) -> Self {
640 Self {
641 config,
642 seen: HashSet::new(),
643 in_process_locks,
644 idempotent_repo,
645 runtime,
646 }
647 }
648}
649
650#[async_trait]
651impl Consumer for FileConsumer {
652 async fn start(&mut self, context: ConsumerContext) -> Result<(), CamelError> {
653 let config = self.config.clone();
654
655 let include_re = config
656 .include
657 .as_ref()
658 .map(|p| Regex::new(p))
659 .transpose()
660 .map_err(|e| CamelError::InvalidUri(format!("invalid include regex: {e}")))?;
661 let exclude_re = config
662 .exclude
663 .as_ref()
664 .map(|p| Regex::new(p))
665 .transpose()
666 .map_err(|e| CamelError::InvalidUri(format!("invalid exclude regex: {e}")))?;
667
668 if !config.initial_delay.is_zero() {
669 tokio::select! {
670 _ = time::sleep(config.initial_delay) => {}
671 _ = context.cancelled() => {
672 debug!(directory = config.directory, "File consumer cancelled during initial delay");
673 return Ok(());
674 }
675 }
676 }
677
678 let mut interval = time::interval(config.delay);
679
680 loop {
681 tokio::select! {
682 _ = context.cancelled() => {
683 debug!(directory = config.directory, "File consumer received cancellation, stopping");
684 break;
685 }
686 _ = interval.tick() => {
687 if let Err(e) = poll_directory(
688 &config,
689 &context,
690 &include_re,
691 &exclude_re,
692 &mut self.seen,
693 &self.in_process_locks,
694 &self.idempotent_repo,
695 ).await {
696 warn!(directory = config.directory, error = %e, "Error polling directory");
697 }
698 }
699 }
700 }
701
702 Ok(())
703 }
704
705 async fn stop(&mut self) -> Result<(), CamelError> {
706 Ok(())
707 }
708}
709
710fn path_contains_traversal(path: &str) -> bool {
716 std::path::Path::new(path)
717 .components()
718 .any(|c| matches!(c, std::path::Component::ParentDir))
719}
720
721fn is_valid_temp_prefix(prefix: &str) -> bool {
722 !prefix.contains('\0')
723 && !std::path::Path::new(prefix).is_absolute()
724 && !prefix.contains(std::path::MAIN_SEPARATOR)
725 && !prefix.contains('/')
726 && !prefix.contains('\\')
727}
728
729fn validate_path_is_within_base(
730 base_dir: &std::path::Path,
731 target_path: &std::path::Path,
732) -> Result<(), CamelError> {
733 if base_dir.exists() {
737 let canonical_base = base_dir.canonicalize().map_err(|e| {
738 CamelError::ProcessorError(format!("Cannot canonicalize base directory: {}", e))
739 })?;
740
741 let canonical_target = if target_path.exists() {
742 target_path.canonicalize().map_err(|e| {
743 CamelError::ProcessorError(format!("Cannot canonicalize target path: {}", e))
744 })?
745 } else if let Some(parent) = target_path.parent() {
746 if parent.exists() {
747 let canonical_parent = parent.canonicalize().map_err(|e| {
748 CamelError::ProcessorError(format!(
749 "Cannot canonicalize parent directory: {}",
750 e
751 ))
752 })?;
753 if let Some(filename) = target_path.file_name() {
754 canonical_parent.join(filename)
755 } else {
756 return Err(CamelError::ProcessorError(
757 "Invalid target path: no filename".to_string(),
758 ));
759 }
760 } else {
761 let rel = target_path.strip_prefix(base_dir).map_err(|_| {
763 CamelError::ProcessorError(format!(
764 "Path '{}' is not under base '{}'",
765 target_path.display(),
766 base_dir.display()
767 ))
768 })?;
769 if path_contains_traversal(&rel.to_string_lossy()) {
770 return Err(CamelError::ProcessorError(format!(
771 "Path '{}' contains directory traversal",
772 target_path.display()
773 )));
774 }
775 return Ok(());
776 }
777 } else {
778 return Err(CamelError::ProcessorError(
779 "Invalid target path: no parent directory".to_string(),
780 ));
781 };
782
783 if !canonical_target.starts_with(&canonical_base) {
784 return Err(CamelError::ProcessorError(format!(
785 "Path '{}' is outside base directory '{}'",
786 canonical_target.display(),
787 canonical_base.display()
788 )));
789 }
790 } else {
791 let rel = target_path.strip_prefix(base_dir).map_err(|_| {
794 CamelError::ProcessorError(format!(
795 "Path '{}' is not under base '{}'",
796 target_path.display(),
797 base_dir.display()
798 ))
799 })?;
800 let rel_str = rel.to_string_lossy();
801 if path_contains_traversal(&rel_str) {
802 return Err(CamelError::ProcessorError(format!(
803 "Path '{}' contains directory traversal",
804 target_path.display()
805 )));
806 }
807 }
808
809 Ok(())
810}
811
812#[derive(Clone)]
817struct FileProducer {
818 config: FileConfig,
819}
820
821impl FileProducer {
822 async fn resolve_filename(
823 exchange: &Exchange,
824 config: &FileConfig,
825 ) -> Result<String, CamelError> {
826 let raw = if let Some(name) = exchange
827 .input
828 .header("CamelFileName")
829 .and_then(|v| v.as_str())
830 {
831 Some(name.to_string())
832 } else {
833 config.file_name.clone()
834 };
835
836 match raw {
837 Some(name) if name.contains("${") => {
838 let lang = SimpleLanguage::new();
839 let expr = lang.create_expression(&name).map_err(|e| {
840 CamelError::ProcessorError(format!(
841 "cannot parse fileName expression '{}': {e}",
842 name
843 ))
844 })?;
845 let val = expr.evaluate(exchange).await.map_err(|e| {
846 CamelError::ProcessorError(format!(
847 "cannot evaluate fileName expression '{}': {e}",
848 name
849 ))
850 })?;
851 match val {
852 serde_json::Value::String(s) => Ok(s),
853 other => Ok(other.to_string()),
854 }
855 }
856 Some(name) => Ok(name),
857 None => Err(CamelError::ProcessorError(
858 "No filename specified: set CamelFileName header or fileName option".to_string(),
859 )),
860 }
861 }
862}
863
864impl Service<Exchange> for FileProducer {
865 type Response = Exchange;
866 type Error = CamelError;
867 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
868
869 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
870 Poll::Ready(Ok(()))
871 }
872
873 fn call(&mut self, mut exchange: Exchange) -> Self::Future {
874 let config = self.config.clone();
875
876 Box::pin(async move {
877 let file_name = FileProducer::resolve_filename(&exchange, &config).await?;
878 let body = exchange.input.body.clone();
879
880 let dir_path = std::path::Path::new(&config.directory);
881 let target_path = dir_path.join(&file_name);
882
883 validate_path_is_within_base(dir_path, &target_path)?;
885
886 if config.auto_create
888 && let Some(parent) = target_path.parent()
889 {
890 tokio::time::timeout(config.write_timeout, fs::create_dir_all(parent))
891 .await
892 .map_err(|_| CamelError::ProcessorError("Timeout creating directories".into()))?
893 .map_err(CamelError::from)?;
894 }
895
896 match config.file_exist {
898 FileExistStrategy::Fail => {
899 let mut file = tokio::time::timeout(
900 config.write_timeout,
901 OpenOptions::new()
902 .write(true)
903 .create_new(true)
904 .open(&target_path),
905 )
906 .await
907 .map_err(|_| {
908 CamelError::ProcessorError("Timeout opening file with create_new".into())
909 })?
910 .map_err(CamelError::from)?;
911
912 write_body_with_charset(body, &config.charset, &mut file, config.write_timeout)
913 .await?;
914 file.flush().await.map_err(CamelError::from)?;
915 }
916 FileExistStrategy::Ignore if target_path.exists() => return Ok(exchange),
917 FileExistStrategy::Append => {
918 let mut file = tokio::time::timeout(
920 config.write_timeout,
921 OpenOptions::new()
922 .append(true)
923 .create(true)
924 .open(&target_path),
925 )
926 .await
927 .map_err(|_| {
928 CamelError::ProcessorError("Timeout opening file for append".into())
929 })?
930 .map_err(CamelError::from)?;
931
932 write_body_with_charset(body, &config.charset, &mut file, config.write_timeout)
933 .await?;
934
935 file.flush().await.map_err(CamelError::from)?;
936 }
937 _ => {
938 let temp_name = if let Some(ref prefix) = config.temp_prefix {
940 format!("{prefix}{file_name}")
941 } else {
942 format!(".tmp.{file_name}")
943 };
944 let temp_path = dir_path.join(&temp_name);
945
946 let mut guard = TempFileGuard::new(temp_path.clone());
948
949 let mut file =
951 tokio::time::timeout(config.write_timeout, fs::File::create(&temp_path))
952 .await
953 .map_err(|_| {
954 CamelError::ProcessorError("Timeout creating temp file".into())
955 })?
956 .map_err(CamelError::from)?;
957
958 let copy_result = write_body_with_charset(
959 body,
960 &config.charset,
961 &mut file,
962 config.write_timeout,
963 )
964 .await;
965
966 let _ = file.flush().await;
968
969 copy_result?;
970
971 let rename_result = tokio::time::timeout(
973 config.write_timeout,
974 fs::rename(&temp_path, &target_path),
975 )
976 .await;
977
978 match rename_result {
979 Ok(Ok(_)) => {
980 guard.disarm();
982 }
983 Ok(Err(e)) => {
984 return Err(CamelError::from(e));
986 }
987 Err(_) => {
988 return Err(CamelError::ProcessorError("Timeout renaming file".into()));
990 }
991 }
992 }
993 }
994
995 if let Some(done_pattern) = &config.done_file_name {
996 let done_name = done_pattern.replace("${file:name}", &file_name);
997 tokio::time::timeout(
998 config.write_timeout,
999 fs::write(dir_path.join(done_name), []),
1000 )
1001 .await
1002 .map_err(|_| CamelError::ProcessorError("Timeout creating done file".into()))?
1003 .map_err(CamelError::from)?;
1004 }
1005
1006 let abs_path = target_path
1008 .canonicalize()
1009 .unwrap_or_else(|_| target_path.clone())
1010 .to_string_lossy()
1011 .to_string();
1012 exchange
1013 .input
1014 .set_header("CamelFileNameProduced", serde_json::Value::String(abs_path));
1015
1016 debug!(
1017 file = %target_path.display(),
1018 correlation_id = %exchange.correlation_id(),
1019 "File written"
1020 );
1021
1022 Ok(exchange)
1023 })
1024 }
1025}
1026
1027async fn write_body_with_charset(
1028 body: Body,
1029 charset: &Option<String>,
1030 file: &mut fs::File,
1031 timeout: Duration,
1032) -> Result<(), CamelError> {
1033 match body {
1034 Body::Text(text) | Body::Xml(text) => {
1035 let bytes = encode_text_by_charset(&text, charset)?;
1036 tokio::time::timeout(timeout, file.write_all(&bytes))
1037 .await
1038 .map_err(|_| CamelError::ProcessorError("Timeout writing file".into()))?
1039 .map_err(CamelError::from)?;
1040 Ok(())
1041 }
1042 other => {
1043 let mut reader = other.into_async_read()?;
1044 tokio::time::timeout(timeout, io::copy(&mut reader, file))
1045 .await
1046 .map_err(|_| CamelError::ProcessorError("Timeout writing to file".into()))?
1047 .map_err(|e| CamelError::ProcessorError(e.to_string()))?;
1048 Ok(())
1049 }
1050 }
1051}
1052
1053fn encode_text_by_charset(text: &str, charset: &Option<String>) -> Result<Vec<u8>, CamelError> {
1054 let Some(charset) = charset.as_ref() else {
1055 return Ok(text.as_bytes().to_vec());
1056 };
1057
1058 if charset.eq_ignore_ascii_case("utf-8") {
1059 return Ok(text.as_bytes().to_vec());
1060 }
1061
1062 if charset.eq_ignore_ascii_case("iso-8859-1") {
1063 let mut out = Vec::with_capacity(text.len());
1064 for ch in text.chars() {
1065 let code = ch as u32;
1066 if code > 0xFF {
1067 return Err(CamelError::ProcessorError(format!(
1068 "character '{ch}' cannot be encoded as ISO-8859-1"
1069 )));
1070 }
1071 out.push(code as u8);
1072 }
1073 return Ok(out);
1074 }
1075
1076 Err(CamelError::Config(format!(
1077 "unsupported charset '{charset}', supported: UTF-8, ISO-8859-1"
1078 )))
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083 use camel_component_api::test_support::PanicRuntimeObservability;
1084 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
1085 std::sync::Arc::new(PanicRuntimeObservability)
1086 }
1087
1088 use super::*;
1089 use crate::poll_logic::{ModificationDetectingStream, list_files};
1090 use bytes::Bytes;
1091 use camel_component_api::{Message, NoOpComponentContext, StreamBody, StreamMetadata};
1092 use futures::StreamExt;
1093 use std::time::Duration;
1094 use tokio_util::io::ReaderStream;
1095 use tokio_util::sync::CancellationToken;
1096
1097 fn test_producer_ctx() -> ProducerContext {
1098 ProducerContext::new()
1099 }
1100
1101 #[test]
1102 fn test_file_config_defaults() {
1103 let config = FileConfig::from_uri("file:/tmp/inbox").unwrap();
1104 assert_eq!(config.directory, "/tmp/inbox");
1105 assert_eq!(config.delay, Duration::from_millis(500));
1106 assert_eq!(config.initial_delay, Duration::from_millis(1000));
1107 assert!(!config.noop);
1108 assert!(!config.delete);
1109 assert_eq!(config.move_to, Some(".camel".to_string()));
1110 assert!(config.file_name.is_none());
1111 assert!(config.include.is_none());
1112 assert!(config.exclude.is_none());
1113 assert!(!config.recursive);
1114 assert_eq!(config.file_exist, FileExistStrategy::Override);
1115 assert!(config.temp_prefix.is_none());
1116 assert!(config.auto_create);
1117 assert_eq!(config.read_timeout, Duration::from_secs(30));
1119 assert_eq!(config.write_timeout, Duration::from_secs(30));
1120 }
1121
1122 #[test]
1123 fn test_file_config_consumer_options() {
1124 let config = FileConfig::from_uri(
1125 "file:/data/input?delay=1000&initialDelay=2000&noop=true&recursive=true&include=.*\\.csv"
1126 ).unwrap();
1127 assert_eq!(config.directory, "/data/input");
1128 assert_eq!(config.delay, Duration::from_millis(1000));
1129 assert_eq!(config.initial_delay, Duration::from_millis(2000));
1130 assert!(config.noop);
1131 assert!(config.recursive);
1132 assert_eq!(config.include, Some(".*\\.csv".to_string()));
1133 }
1134
1135 #[test]
1136 fn test_file_config_producer_options() {
1137 let config = FileConfig::from_uri(
1138 "file:/data/output?fileExist=Append&tempPrefix=.tmp&autoCreate=false&fileName=out.txt",
1139 )
1140 .unwrap();
1141 assert_eq!(config.file_exist, FileExistStrategy::Append);
1142 assert_eq!(config.temp_prefix, Some(".tmp".to_string()));
1143 assert!(!config.auto_create);
1144 assert_eq!(config.file_name, Some("out.txt".to_string()));
1145 }
1146
1147 #[test]
1148 fn test_file_config_delete_mode() {
1149 let config = FileConfig::from_uri("file:/tmp/inbox?delete=true").unwrap();
1150 assert!(config.delete);
1151 assert!(config.move_to.is_none());
1152 }
1153
1154 #[test]
1155 fn test_file_config_noop_mode() {
1156 let config = FileConfig::from_uri("file:/tmp/inbox?noop=true").unwrap();
1157 assert!(config.noop);
1158 assert!(config.move_to.is_none());
1159 }
1160
1161 #[test]
1162 fn test_file_config_wrong_scheme() {
1163 let result = FileConfig::from_uri("timer:tick");
1164 assert!(result.is_err());
1165 }
1166
1167 #[test]
1168 fn test_file_component_scheme() {
1169 let component = FileComponent::new();
1170 assert_eq!(component.scheme(), "file");
1171 }
1172
1173 #[test]
1174 fn test_file_component_creates_endpoint() {
1175 let component = FileComponent::new();
1176 let ctx = NoOpComponentContext;
1177 let endpoint = component.create_endpoint("file:/tmp/test", &ctx);
1178 assert!(endpoint.is_ok());
1179 }
1180
1181 #[tokio::test]
1186 async fn test_file_consumer_reads_files() {
1187 let dir = tempfile::tempdir().unwrap();
1188 let dir_path = dir.path().to_str().unwrap();
1189
1190 std::fs::write(dir.path().join("test1.txt"), "hello").unwrap();
1191 std::fs::write(dir.path().join("test2.txt"), "world").unwrap();
1192
1193 let component = FileComponent::new();
1194 let ctx = NoOpComponentContext;
1195 let endpoint = component
1196 .create_endpoint(
1197 &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100"),
1198 &ctx,
1199 )
1200 .unwrap();
1201 let mut consumer = endpoint.create_consumer(rt()).unwrap();
1202
1203 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1204 let token = CancellationToken::new();
1205 let ctx = ConsumerContext::new(tx, token.clone());
1206
1207 tokio::spawn(async move {
1208 consumer.start(ctx).await.unwrap();
1209 });
1210
1211 let mut received = Vec::new();
1212 let timeout = tokio::time::timeout(Duration::from_secs(2), async {
1213 while let Some(envelope) = rx.recv().await {
1214 received.push(envelope.exchange);
1215 if received.len() == 2 {
1216 break;
1217 }
1218 }
1219 })
1220 .await;
1221 token.cancel();
1222
1223 assert!(timeout.is_ok(), "Should have received 2 exchanges");
1224 assert_eq!(received.len(), 2);
1225
1226 for ex in &received {
1227 assert!(ex.input.header("CamelFileName").is_some());
1228 assert!(ex.input.header("CamelFileNameOnly").is_some());
1229 assert!(ex.input.header("CamelFileAbsolutePath").is_some());
1230 assert!(ex.input.header("CamelFileLength").is_some());
1231 assert!(ex.input.header("CamelFileLastModified").is_some());
1232 }
1233 }
1234
1235 #[tokio::test]
1236 async fn noop_second_poll_does_not_re_emit_seen_files() {
1237 let dir = tempfile::tempdir().unwrap();
1238 let file_path = dir.path().join("test.txt");
1239 tokio::fs::write(&file_path, b"hello").await.unwrap();
1240
1241 let uri = format!(
1242 "file:{}?noop=true&initialDelay=0&delay=50",
1243 dir.path().display()
1244 );
1245 let config = FileConfig::from_uri(&uri).unwrap();
1246 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1247 let token = CancellationToken::new();
1248 let ctx = ConsumerContext::new(tx, token);
1249
1250 let include_re = None;
1251 let exclude_re = None;
1252 let mut seen = std::collections::HashSet::new();
1253 let in_process_locks = std::sync::Arc::new(DashMap::new());
1254 let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
1255
1256 poll_directory(
1257 &config,
1258 &ctx,
1259 &include_re,
1260 &exclude_re,
1261 &mut seen,
1262 &in_process_locks,
1263 &idempotent_repo,
1264 )
1265 .await
1266 .unwrap();
1267 assert!(rx.try_recv().is_ok(), "first poll should emit file");
1268 assert!(rx.try_recv().is_err(), "should only emit once");
1269
1270 poll_directory(
1271 &config,
1272 &ctx,
1273 &include_re,
1274 &exclude_re,
1275 &mut seen,
1276 &in_process_locks,
1277 &idempotent_repo,
1278 )
1279 .await
1280 .unwrap();
1281 assert!(
1282 rx.try_recv().is_err(),
1283 "second poll should not re-emit seen file"
1284 );
1285 }
1286
1287 #[tokio::test]
1288 async fn noop_new_files_picked_up_after_first_poll() {
1289 let dir = tempfile::tempdir().unwrap();
1290 let file1 = dir.path().join("a.txt");
1291 tokio::fs::write(&file1, b"a").await.unwrap();
1292
1293 let uri = format!(
1294 "file:{}?noop=true&initialDelay=0&delay=50",
1295 dir.path().display()
1296 );
1297 let config = FileConfig::from_uri(&uri).unwrap();
1298 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1299 let token = CancellationToken::new();
1300 let ctx = ConsumerContext::new(tx, token);
1301
1302 let include_re = None;
1303 let exclude_re = None;
1304 let mut seen = std::collections::HashSet::new();
1305 let in_process_locks = std::sync::Arc::new(DashMap::new());
1306 let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
1307
1308 poll_directory(
1309 &config,
1310 &ctx,
1311 &include_re,
1312 &exclude_re,
1313 &mut seen,
1314 &in_process_locks,
1315 &idempotent_repo,
1316 )
1317 .await
1318 .unwrap();
1319 let _ = rx.try_recv();
1320
1321 let file2 = dir.path().join("b.txt");
1322 tokio::fs::write(&file2, b"b").await.unwrap();
1323
1324 poll_directory(
1325 &config,
1326 &ctx,
1327 &include_re,
1328 &exclude_re,
1329 &mut seen,
1330 &in_process_locks,
1331 &idempotent_repo,
1332 )
1333 .await
1334 .unwrap();
1335 assert!(
1336 rx.try_recv().is_ok(),
1337 "b.txt should be emitted on second poll"
1338 );
1339 assert!(rx.try_recv().is_err(), "a.txt should not be re-emitted");
1340 }
1341
1342 #[tokio::test]
1343 async fn test_file_consumer_include_filter() {
1344 let dir = tempfile::tempdir().unwrap();
1345 let dir_path = dir.path().to_str().unwrap();
1346
1347 std::fs::write(dir.path().join("data.csv"), "a,b,c").unwrap();
1348 std::fs::write(dir.path().join("readme.txt"), "hello").unwrap();
1349
1350 let component = FileComponent::new();
1351 let ctx = NoOpComponentContext;
1352 let endpoint = component
1353 .create_endpoint(
1354 &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&include=.*\\.csv"),
1355 &ctx,
1356 )
1357 .unwrap();
1358 let mut consumer = endpoint.create_consumer(rt()).unwrap();
1359
1360 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1361 let token = CancellationToken::new();
1362 let ctx = ConsumerContext::new(tx, token.clone());
1363
1364 tokio::spawn(async move {
1365 consumer.start(ctx).await.unwrap();
1366 });
1367
1368 let mut received = Vec::new();
1369 let _ = tokio::time::timeout(Duration::from_millis(500), async {
1370 while let Some(envelope) = rx.recv().await {
1371 received.push(envelope.exchange);
1372 if received.len() == 1 {
1373 break;
1374 }
1375 }
1376 })
1377 .await;
1378 token.cancel();
1379
1380 assert_eq!(received.len(), 1);
1381 let name = received[0]
1382 .input
1383 .header("CamelFileNameOnly")
1384 .and_then(|v| v.as_str())
1385 .unwrap();
1386 assert_eq!(name, "data.csv");
1387 }
1388
1389 #[tokio::test]
1390 async fn test_file_consumer_delete_mode() {
1391 let dir = tempfile::tempdir().unwrap();
1392 let dir_path = dir.path().to_str().unwrap();
1393
1394 std::fs::write(dir.path().join("deleteme.txt"), "bye").unwrap();
1395
1396 let component = FileComponent::new();
1397 let ctx = NoOpComponentContext;
1398 let endpoint = component
1399 .create_endpoint(
1400 &format!("file:{dir_path}?delete=true&initialDelay=0&delay=100"),
1401 &ctx,
1402 )
1403 .unwrap();
1404 let mut consumer = endpoint.create_consumer(rt()).unwrap();
1405
1406 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1407 let token = CancellationToken::new();
1408 let ctx = ConsumerContext::new(tx, token.clone());
1409
1410 tokio::spawn(async move {
1411 consumer.start(ctx).await.unwrap();
1412 });
1413
1414 let _ = tokio::time::timeout(Duration::from_millis(500), async { rx.recv().await }).await;
1415 token.cancel();
1416
1417 tokio::time::sleep(Duration::from_millis(100)).await;
1418
1419 assert!(
1420 !dir.path().join("deleteme.txt").exists(),
1421 "File should be deleted"
1422 );
1423 }
1424
1425 #[tokio::test]
1426 async fn test_file_consumer_move_mode() {
1427 let dir = tempfile::tempdir().unwrap();
1428 tokio::fs::write(dir.path().join("moveme.txt"), b"data")
1429 .await
1430 .unwrap();
1431
1432 let uri = format!("file:{}?initialDelay=0&delay=50", dir.path().display());
1433 let config = FileConfig::from_uri(&uri).unwrap();
1434 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1435 let token = CancellationToken::new();
1436 let ctx = ConsumerContext::new(tx, token);
1437
1438 let include_re = None;
1439 let exclude_re = None;
1440 let mut seen = std::collections::HashSet::new();
1441 let in_process_locks = std::sync::Arc::new(DashMap::new());
1442 let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
1443
1444 poll_directory(
1445 &config,
1446 &ctx,
1447 &include_re,
1448 &exclude_re,
1449 &mut seen,
1450 &in_process_locks,
1451 &idempotent_repo,
1452 )
1453 .await
1454 .unwrap();
1455
1456 let ex = rx.try_recv().expect("should receive exchange");
1457 drop(ex);
1458
1459 assert!(
1460 !dir.path().join("moveme.txt").exists(),
1461 "Original file should be gone"
1462 );
1463 assert!(
1464 dir.path().join(".camel").join("moveme.txt").exists(),
1465 "File should be in .camel/"
1466 );
1467 }
1468
1469 #[tokio::test]
1470 async fn test_file_consumer_respects_cancellation() {
1471 let dir = tempfile::tempdir().unwrap();
1472 let dir_path = dir.path().to_str().unwrap();
1473
1474 let component = FileComponent::new();
1475 let ctx = NoOpComponentContext;
1476 let endpoint = component
1477 .create_endpoint(&format!("file:{dir_path}?initialDelay=0&delay=50"), &ctx)
1478 .unwrap();
1479 let mut consumer = endpoint.create_consumer(rt()).unwrap();
1480
1481 let (tx, _rx) = tokio::sync::mpsc::channel(16);
1482 let token = CancellationToken::new();
1483 let ctx = ConsumerContext::new(tx, token.clone());
1484
1485 let handle = tokio::spawn(async move {
1486 consumer.start(ctx).await.unwrap();
1487 });
1488
1489 tokio::time::sleep(Duration::from_millis(150)).await;
1490 token.cancel();
1491
1492 let result = tokio::time::timeout(Duration::from_secs(1), handle).await;
1493 assert!(
1494 result.is_ok(),
1495 "Consumer should have stopped after cancellation"
1496 );
1497 }
1498
1499 #[tokio::test]
1504 async fn test_file_producer_writes_file() {
1505 use tower::ServiceExt;
1506
1507 let dir = tempfile::tempdir().unwrap();
1508 let dir_path = dir.path().to_str().unwrap();
1509
1510 let component = FileComponent::new();
1511 let ctx = NoOpComponentContext;
1512 let endpoint = component
1513 .create_endpoint(&format!("file:{dir_path}"), &ctx)
1514 .unwrap();
1515 let ctx = test_producer_ctx();
1516 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1517
1518 let mut exchange = Exchange::new(Message::new("file content"));
1519 exchange.input.set_header(
1520 "CamelFileName",
1521 serde_json::Value::String("output.txt".to_string()),
1522 );
1523
1524 let result = producer.oneshot(exchange).await.unwrap();
1525
1526 let content = std::fs::read_to_string(dir.path().join("output.txt")).unwrap();
1527 assert_eq!(content, "file content");
1528
1529 assert!(result.input.header("CamelFileNameProduced").is_some());
1530 }
1531
1532 #[tokio::test]
1533 async fn test_file_producer_auto_create_dirs() {
1534 use tower::ServiceExt;
1535
1536 let dir = tempfile::tempdir().unwrap();
1537 let dir_path = dir.path().to_str().unwrap();
1538
1539 let component = FileComponent::new();
1540 let ctx = NoOpComponentContext;
1541 let endpoint = component
1542 .create_endpoint(&format!("file:{dir_path}/sub/dir"), &ctx)
1543 .unwrap();
1544 let ctx = test_producer_ctx();
1545 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1546
1547 let mut exchange = Exchange::new(Message::new("nested"));
1548 exchange.input.set_header(
1549 "CamelFileName",
1550 serde_json::Value::String("file.txt".to_string()),
1551 );
1552
1553 producer.oneshot(exchange).await.unwrap();
1554
1555 assert!(dir.path().join("sub/dir/file.txt").exists());
1556 }
1557
1558 #[tokio::test]
1559 async fn test_file_producer_file_exist_fail() {
1560 use tower::ServiceExt;
1561
1562 let dir = tempfile::tempdir().unwrap();
1563 let dir_path = dir.path().to_str().unwrap();
1564
1565 std::fs::write(dir.path().join("existing.txt"), "old").unwrap();
1566
1567 let component = FileComponent::new();
1568 let ctx = NoOpComponentContext;
1569 let endpoint = component
1570 .create_endpoint(&format!("file:{dir_path}?fileExist=Fail"), &ctx)
1571 .unwrap();
1572 let ctx = test_producer_ctx();
1573 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1574
1575 let mut exchange = Exchange::new(Message::new("new"));
1576 exchange.input.set_header(
1577 "CamelFileName",
1578 serde_json::Value::String("existing.txt".to_string()),
1579 );
1580
1581 let result = producer.oneshot(exchange).await;
1582 assert!(
1583 result.is_err(),
1584 "Should fail when file exists with Fail strategy"
1585 );
1586 }
1587
1588 #[tokio::test]
1589 async fn test_file_producer_file_exist_append() {
1590 use tower::ServiceExt;
1591
1592 let dir = tempfile::tempdir().unwrap();
1593 let dir_path = dir.path().to_str().unwrap();
1594
1595 std::fs::write(dir.path().join("append.txt"), "old").unwrap();
1596
1597 let component = FileComponent::new();
1598 let ctx = NoOpComponentContext;
1599 let endpoint = component
1600 .create_endpoint(&format!("file:{dir_path}?fileExist=Append"), &ctx)
1601 .unwrap();
1602 let ctx = test_producer_ctx();
1603 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1604
1605 let mut exchange = Exchange::new(Message::new("new"));
1606 exchange.input.set_header(
1607 "CamelFileName",
1608 serde_json::Value::String("append.txt".to_string()),
1609 );
1610
1611 producer.oneshot(exchange).await.unwrap();
1612
1613 let content = std::fs::read_to_string(dir.path().join("append.txt")).unwrap();
1614 assert_eq!(content, "oldnew");
1615 }
1616
1617 #[tokio::test]
1618 async fn test_file_producer_temp_prefix() {
1619 use tower::ServiceExt;
1620
1621 let dir = tempfile::tempdir().unwrap();
1622 let dir_path = dir.path().to_str().unwrap();
1623
1624 let component = FileComponent::new();
1625 let ctx = NoOpComponentContext;
1626 let endpoint = component
1627 .create_endpoint(&format!("file:{dir_path}?tempPrefix=.tmp"), &ctx)
1628 .unwrap();
1629 let ctx = test_producer_ctx();
1630 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1631
1632 let mut exchange = Exchange::new(Message::new("atomic write"));
1633 exchange.input.set_header(
1634 "CamelFileName",
1635 serde_json::Value::String("final.txt".to_string()),
1636 );
1637
1638 producer.oneshot(exchange).await.unwrap();
1639
1640 assert!(dir.path().join("final.txt").exists());
1641 assert!(!dir.path().join(".tmpfinal.txt").exists());
1642 let content = std::fs::read_to_string(dir.path().join("final.txt")).unwrap();
1643 assert_eq!(content, "atomic write");
1644 }
1645
1646 #[tokio::test]
1647 async fn test_file_producer_uses_filename_option() {
1648 use tower::ServiceExt;
1649
1650 let dir = tempfile::tempdir().unwrap();
1651 let dir_path = dir.path().to_str().unwrap();
1652
1653 let component = FileComponent::new();
1654 let ctx = NoOpComponentContext;
1655 let endpoint = component
1656 .create_endpoint(&format!("file:{dir_path}?fileName=fixed.txt"), &ctx)
1657 .unwrap();
1658 let ctx = test_producer_ctx();
1659 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1660
1661 let exchange = Exchange::new(Message::new("content"));
1662
1663 producer.oneshot(exchange).await.unwrap();
1664 assert!(dir.path().join("fixed.txt").exists());
1665 }
1666
1667 #[tokio::test]
1668 async fn test_file_producer_no_filename_errors() {
1669 use tower::ServiceExt;
1670
1671 let dir = tempfile::tempdir().unwrap();
1672 let dir_path = dir.path().to_str().unwrap();
1673
1674 let component = FileComponent::new();
1675 let ctx = NoOpComponentContext;
1676 let endpoint = component
1677 .create_endpoint(&format!("file:{dir_path}"), &ctx)
1678 .unwrap();
1679 let ctx = test_producer_ctx();
1680 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1681
1682 let exchange = Exchange::new(Message::new("content"));
1683
1684 let result = producer.oneshot(exchange).await;
1685 assert!(result.is_err(), "Should error when no filename is provided");
1686 }
1687
1688 #[tokio::test]
1693 async fn test_file_producer_rejects_path_traversal_parent_directory() {
1694 use tower::ServiceExt;
1695
1696 let dir = tempfile::tempdir().unwrap();
1697 let dir_path = dir.path().to_str().unwrap();
1698
1699 std::fs::create_dir(dir.path().join("subdir")).unwrap();
1701 std::fs::write(dir.path().join("secret.txt"), "secret").unwrap();
1702
1703 let component = FileComponent::new();
1704 let ctx = NoOpComponentContext;
1705 let endpoint = component
1706 .create_endpoint(&format!("file:{dir_path}/subdir"), &ctx)
1707 .unwrap();
1708 let ctx = test_producer_ctx();
1709 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1710
1711 let mut exchange = Exchange::new(Message::new("malicious"));
1712 exchange.input.set_header(
1713 "CamelFileName",
1714 serde_json::Value::String("../secret.txt".to_string()),
1715 );
1716
1717 let result = producer.oneshot(exchange).await;
1718 assert!(result.is_err(), "Should reject path traversal attempt");
1719
1720 let err = result.unwrap_err();
1721 assert!(
1722 err.to_string().contains("outside"),
1723 "Error should mention path is outside base directory"
1724 );
1725 }
1726
1727 #[tokio::test]
1728 async fn test_file_producer_rejects_absolute_path_outside_base() {
1729 use tower::ServiceExt;
1730
1731 let dir = tempfile::tempdir().unwrap();
1732 let dir_path = dir.path().to_str().unwrap();
1733
1734 let component = FileComponent::new();
1735 let ctx = NoOpComponentContext;
1736 let endpoint = component
1737 .create_endpoint(&format!("file:{dir_path}"), &ctx)
1738 .unwrap();
1739 let ctx = test_producer_ctx();
1740 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1741
1742 let mut exchange = Exchange::new(Message::new("malicious"));
1743 exchange.input.set_header(
1744 "CamelFileName",
1745 serde_json::Value::String("/etc/passwd".to_string()),
1746 );
1747
1748 let result = producer.oneshot(exchange).await;
1749 assert!(result.is_err(), "Should reject absolute path outside base");
1750 }
1751
1752 #[tokio::test]
1753 async fn test_file_producer_does_not_create_dirs_before_path_validation() {
1754 use tower::ServiceExt;
1755
1756 let dir = tempfile::tempdir().unwrap();
1757 let dir_path = dir.path().to_str().unwrap();
1758
1759 let component = FileComponent::new();
1760 let ctx = NoOpComponentContext;
1761 let endpoint = component
1762 .create_endpoint(&format!("file:{dir_path}"), &ctx)
1763 .unwrap();
1764 let ctx = test_producer_ctx();
1765 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1766
1767 let outside_parent = dir.path().parent().unwrap().join("escaped-create-dir");
1768 if outside_parent.exists() {
1769 std::fs::remove_dir_all(&outside_parent).unwrap();
1770 }
1771
1772 let mut exchange = Exchange::new(Message::new("malicious"));
1773 exchange.input.set_header(
1774 "CamelFileName",
1775 serde_json::Value::String("../escaped-create-dir/file.txt".to_string()),
1776 );
1777
1778 let result = producer.oneshot(exchange).await;
1779 assert!(result.is_err(), "Should reject traversal path");
1780 assert!(
1781 !outside_parent.exists(),
1782 "Must not create outside directories before path validation"
1783 );
1784 }
1785
1786 #[cfg(unix)]
1787 #[tokio::test]
1788 async fn test_list_files_skips_symlink_cycle() {
1789 use std::os::unix::fs as unix_fs;
1790
1791 let dir = tempfile::tempdir().unwrap();
1792 let nested = dir.path().join("nested");
1793 std::fs::create_dir_all(&nested).unwrap();
1794 std::fs::write(nested.join("a.txt"), "x").unwrap();
1795 unix_fs::symlink(dir.path(), nested.join("loop")).unwrap();
1796
1797 let files = list_files(dir.path(), true).await.unwrap();
1798 assert_eq!(files.iter().filter(|p| p.ends_with("a.txt")).count(), 1);
1799 }
1800
1801 #[tokio::test]
1806 #[ignore] async fn test_large_file_streaming_constant_memory() {
1808 use std::io::Write;
1809 use tempfile::NamedTempFile;
1810
1811 let mut temp_file = NamedTempFile::new().unwrap();
1813 let file_size = 150 * 1024 * 1024; let chunk = vec![b'X'; 1024 * 1024]; for _ in 0..150 {
1817 temp_file.write_all(&chunk).unwrap();
1818 }
1819 temp_file.flush().unwrap();
1820
1821 let dir = temp_file.path().parent().unwrap();
1822 let dir_path = dir.to_str().unwrap();
1823 let file_name = temp_file
1824 .path()
1825 .file_name()
1826 .unwrap()
1827 .to_str()
1828 .unwrap()
1829 .to_string();
1830
1831 let component = FileComponent::new();
1833 let component_ctx = NoOpComponentContext;
1834 let endpoint = component
1835 .create_endpoint(
1836 &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&fileName={file_name}"),
1837 &component_ctx,
1838 )
1839 .unwrap();
1840 let mut consumer = endpoint.create_consumer(rt()).unwrap();
1841
1842 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1843 let token = CancellationToken::new();
1844 let ctx = ConsumerContext::new(tx, token.clone());
1845
1846 tokio::spawn(async move {
1847 let _ = consumer.start(ctx).await;
1848 });
1849
1850 let exchange = tokio::time::timeout(Duration::from_secs(5), async {
1851 rx.recv().await.unwrap().exchange
1852 })
1853 .await
1854 .expect("Should receive exchange");
1855 token.cancel();
1856
1857 assert!(matches!(exchange.input.body, Body::Stream(_)));
1859
1860 if let Body::Stream(ref stream_body) = exchange.input.body {
1862 assert!(stream_body.metadata.size_hint.is_some());
1863 let size = stream_body.metadata.size_hint.unwrap();
1864 assert_eq!(size, file_size as u64);
1865 }
1866
1867 if let Body::Stream(stream_body) = exchange.input.body {
1869 let body = Body::Stream(stream_body);
1870 let result = body.into_bytes(100 * 1024 * 1024).await;
1871 assert!(result.is_err());
1872 }
1873
1874 let component2 = FileComponent::new();
1877 let endpoint2 = component2
1878 .create_endpoint(
1879 &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&fileName={file_name}"),
1880 &component_ctx,
1881 )
1882 .unwrap();
1883 let mut consumer2 = endpoint2.create_consumer(rt()).unwrap();
1884
1885 let (tx2, mut rx2) = tokio::sync::mpsc::channel(16);
1886 let token2 = CancellationToken::new();
1887 let ctx2 = ConsumerContext::new(tx2, token2.clone());
1888
1889 tokio::spawn(async move {
1890 let _ = consumer2.start(ctx2).await;
1891 });
1892
1893 let exchange2 = tokio::time::timeout(Duration::from_secs(5), async {
1894 rx2.recv().await.unwrap().exchange
1895 })
1896 .await
1897 .expect("Should receive exchange");
1898 token2.cancel();
1899
1900 if let Body::Stream(stream_body) = exchange2.input.body {
1901 let mut stream_lock = stream_body.stream.lock().await;
1902 let mut stream = stream_lock.take().unwrap();
1903
1904 if let Some(chunk_result) = stream.next().await {
1906 let chunk = chunk_result.unwrap();
1907 assert!(!chunk.is_empty());
1908 assert!(chunk.len() < file_size);
1909 }
1911 }
1912 }
1913
1914 #[tokio::test]
1919 async fn test_producer_writes_stream_body() {
1920 let dir = tempfile::tempdir().unwrap();
1921 let dir_path = dir.path().to_str().unwrap();
1922 let uri = format!("file:{dir_path}?fileName=out.txt");
1923
1924 let component = FileComponent::new();
1925 let ctx = NoOpComponentContext;
1926 let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
1927 let producer = endpoint
1928 .create_producer(rt(), &test_producer_ctx())
1929 .unwrap();
1930
1931 let chunks: Vec<Result<Bytes, CamelError>> = vec![
1932 Ok(Bytes::from("hello ")),
1933 Ok(Bytes::from("streaming ")),
1934 Ok(Bytes::from("world")),
1935 ];
1936 let stream = futures::stream::iter(chunks);
1937 let body = Body::Stream(StreamBody {
1938 stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
1939 metadata: StreamMetadata {
1940 size_hint: None,
1941 content_type: None,
1942 origin: None,
1943 },
1944 });
1945
1946 let exchange = Exchange::new(Message::new(body));
1947 tower::ServiceExt::oneshot(producer, exchange)
1948 .await
1949 .unwrap();
1950
1951 let content = tokio::fs::read_to_string(format!("{dir_path}/out.txt"))
1952 .await
1953 .unwrap();
1954 assert_eq!(content, "hello streaming world");
1955 }
1956
1957 #[tokio::test]
1958 async fn test_producer_stream_atomic_no_partial_on_error() {
1959 let dir = tempfile::tempdir().unwrap();
1961 let dir_path = dir.path().to_str().unwrap();
1962 let uri = format!("file:{dir_path}?fileName=out.txt");
1963
1964 let component = FileComponent::new();
1965 let ctx = NoOpComponentContext;
1966 let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
1967 let producer = endpoint
1968 .create_producer(rt(), &test_producer_ctx())
1969 .unwrap();
1970
1971 let chunks: Vec<Result<Bytes, CamelError>> = vec![
1972 Ok(Bytes::from("partial")),
1973 Err(CamelError::ProcessorError(
1974 "simulated stream error".to_string(),
1975 )),
1976 ];
1977 let stream = futures::stream::iter(chunks);
1978 let body = Body::Stream(StreamBody {
1979 stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
1980 metadata: StreamMetadata {
1981 size_hint: None,
1982 content_type: None,
1983 origin: None,
1984 },
1985 });
1986
1987 let exchange = Exchange::new(Message::new(body));
1988 let result = tower::ServiceExt::oneshot(producer, exchange).await;
1989 assert!(
1990 result.is_err(),
1991 "expected error when stream fails mid-write"
1992 );
1993
1994 assert!(
1996 !std::path::Path::new(&format!("{dir_path}/out.txt")).exists(),
1997 "partial file must not exist after failed write"
1998 );
1999
2000 assert!(
2002 !std::path::Path::new(&format!("{dir_path}/.tmp.out.txt")).exists(),
2003 "temp file must be cleaned up after failed write"
2004 );
2005 }
2006
2007 #[tokio::test]
2008 async fn test_producer_stream_append() {
2009 let dir = tempfile::tempdir().unwrap();
2010 let dir_path = dir.path().to_str().unwrap();
2011 let target = format!("{dir_path}/out.txt");
2012
2013 tokio::fs::write(&target, b"line1\n").await.unwrap();
2015
2016 let uri = format!("file:{dir_path}?fileName=out.txt&fileExist=Append");
2017 let component = FileComponent::new();
2018 let ctx = NoOpComponentContext;
2019 let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2020 let producer = endpoint
2021 .create_producer(rt(), &test_producer_ctx())
2022 .unwrap();
2023
2024 let chunks: Vec<Result<Bytes, CamelError>> = vec![Ok(Bytes::from("line2\n"))];
2025 let stream = futures::stream::iter(chunks);
2026 let body = Body::Stream(StreamBody {
2027 stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2028 metadata: StreamMetadata {
2029 size_hint: None,
2030 content_type: None,
2031 origin: None,
2032 },
2033 });
2034
2035 let exchange = Exchange::new(Message::new(body));
2036 tower::ServiceExt::oneshot(producer, exchange)
2037 .await
2038 .unwrap();
2039
2040 let content = tokio::fs::read_to_string(&target).await.unwrap();
2041 assert_eq!(content, "line1\nline2\n");
2042 }
2043
2044 #[tokio::test]
2045 async fn test_producer_stream_append_partial_on_error() {
2046 let dir = tempfile::tempdir().unwrap();
2049 let dir_path = dir.path().to_str().unwrap();
2050 let target = format!("{dir_path}/out.txt");
2051
2052 tokio::fs::write(&target, b"initial\n").await.unwrap();
2054
2055 let uri = format!("file:{dir_path}?fileName=out.txt&fileExist=Append");
2056 let component = FileComponent::new();
2057 let ctx = NoOpComponentContext;
2058 let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2059 let producer = endpoint
2060 .create_producer(rt(), &test_producer_ctx())
2061 .unwrap();
2062
2063 let chunks: Vec<Result<Bytes, CamelError>> = vec![
2065 Ok(Bytes::from("partial-")), Err(CamelError::ProcessorError("stream error".to_string())), Ok(Bytes::from("never-written")), ];
2069 let stream = futures::stream::iter(chunks);
2070 let body = Body::Stream(StreamBody {
2071 stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2072 metadata: StreamMetadata {
2073 size_hint: None,
2074 content_type: None,
2075 origin: None,
2076 },
2077 });
2078
2079 let exchange = Exchange::new(Message::new(body));
2080 let result = tower::ServiceExt::oneshot(producer, exchange).await;
2081
2082 assert!(
2084 result.is_err(),
2085 "expected error when stream fails during append"
2086 );
2087
2088 let content = tokio::fs::read_to_string(&target).await.unwrap();
2090 assert_eq!(
2091 content, "initial\npartial-",
2092 "append leaves partial data on stream error (non-atomic by nature)"
2093 );
2094 }
2095
2096 #[tokio::test]
2097 async fn test_producer_stream_already_consumed_errors() {
2098 let dir = tempfile::tempdir().unwrap();
2099 let dir_path = dir.path().to_str().unwrap();
2100 let uri = format!("file:{dir_path}?fileName=out.txt");
2101
2102 let component = FileComponent::new();
2103 let ctx = NoOpComponentContext;
2104 let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2105 let producer = endpoint
2106 .create_producer(rt(), &test_producer_ctx())
2107 .unwrap();
2108
2109 type MaybeStream = std::sync::Arc<
2111 tokio::sync::Mutex<
2112 Option<
2113 std::pin::Pin<
2114 Box<dyn futures::Stream<Item = Result<Bytes, CamelError>> + Send>,
2115 >,
2116 >,
2117 >,
2118 >;
2119 let arc: MaybeStream = std::sync::Arc::new(tokio::sync::Mutex::new(None));
2120 let body = Body::Stream(StreamBody {
2121 stream: arc,
2122 metadata: StreamMetadata {
2123 size_hint: None,
2124 content_type: None,
2125 origin: None,
2126 },
2127 });
2128
2129 let exchange = Exchange::new(Message::new(body));
2130 let result = tower::ServiceExt::oneshot(producer, exchange).await;
2131 assert!(
2132 result.is_err(),
2133 "expected error for already-consumed stream"
2134 );
2135 }
2136
2137 #[test]
2142 fn test_global_config_applied_to_endpoint() {
2143 let global = FileGlobalConfig::default()
2145 .with_delay_ms(2000)
2146 .with_initial_delay_ms(5000)
2147 .with_read_timeout_ms(60_000)
2148 .with_write_timeout_ms(45_000);
2149 let component = FileComponent::with_config(global);
2150 let ctx = NoOpComponentContext;
2151 let endpoint = component.create_endpoint("file:/tmp/inbox", &ctx).unwrap();
2153 let mut config = FileConfig::from_uri("file:/tmp/inbox").unwrap();
2156 let global2 = FileGlobalConfig::default()
2157 .with_delay_ms(2000)
2158 .with_initial_delay_ms(5000)
2159 .with_read_timeout_ms(60_000)
2160 .with_write_timeout_ms(45_000);
2161 config.apply_global_defaults(&global2);
2162 assert_eq!(config.delay, Duration::from_millis(2000));
2163 assert_eq!(config.initial_delay, Duration::from_millis(5000));
2164 assert_eq!(config.read_timeout, Duration::from_millis(60_000));
2165 assert_eq!(config.write_timeout, Duration::from_millis(45_000));
2166 let _ = endpoint; }
2169
2170 #[test]
2171 fn test_uri_param_wins_over_global_config() {
2172 let mut config =
2174 FileConfig::from_uri("file:/tmp/inbox?delay=1000&initialDelay=2000").unwrap();
2175 let global = FileGlobalConfig::default()
2177 .with_delay_ms(3000)
2178 .with_initial_delay_ms(4000);
2179 config.apply_global_defaults(&global);
2180 assert_eq!(config.delay, Duration::from_millis(1000));
2182 assert_eq!(config.initial_delay, Duration::from_millis(2000));
2184 assert_eq!(config.read_timeout, Duration::from_millis(30_000));
2187 }
2188
2189 #[tokio::test]
2190 async fn test_file_producer_filename_simple_language_from_header() {
2191 use tower::ServiceExt;
2192
2193 let dir = tempfile::tempdir().unwrap();
2194 let dir_path = dir.path().to_str().unwrap();
2195
2196 let component = FileComponent::new();
2197 let ctx = NoOpComponentContext;
2198 let endpoint = component
2199 .create_endpoint(&format!("file:{dir_path}"), &ctx)
2200 .unwrap();
2201 let ctx = test_producer_ctx();
2202 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2203
2204 let mut exchange = Exchange::new(Message::new("content"));
2205 exchange
2206 .input
2207 .set_header("CamelTimerCounter", serde_json::Value::Number(42.into()));
2208 exchange.input.set_header(
2209 "CamelFileName",
2210 serde_json::Value::String("test-${header.CamelTimerCounter}.txt".to_string()),
2211 );
2212
2213 producer.oneshot(exchange).await.unwrap();
2214
2215 assert!(
2216 dir.path().join("test-42.txt").exists(),
2217 "fileName should have been evaluated from Simple Language expression"
2218 );
2219 let content = std::fs::read_to_string(dir.path().join("test-42.txt")).unwrap();
2220 assert_eq!(content, "content");
2221 }
2222
2223 #[tokio::test]
2224 async fn test_file_producer_filename_simple_language_from_uri_param() {
2225 use tower::ServiceExt;
2226
2227 let dir = tempfile::tempdir().unwrap();
2228 let dir_path = dir.path().to_str().unwrap();
2229
2230 let component = FileComponent::new();
2231 let ctx = NoOpComponentContext;
2232 let endpoint = component
2233 .create_endpoint(
2234 &format!("file:{dir_path}?fileName=msg-${{header.id}}.dat"),
2235 &ctx,
2236 )
2237 .unwrap();
2238 let ctx = test_producer_ctx();
2239 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2240
2241 let mut exchange = Exchange::new(Message::new("data"));
2242 exchange
2243 .input
2244 .set_header("id", serde_json::Value::String("abc".to_string()));
2245
2246 producer.oneshot(exchange).await.unwrap();
2247
2248 assert!(
2249 dir.path().join("msg-abc.dat").exists(),
2250 "fileName URI param should have been evaluated from Simple Language expression"
2251 );
2252 }
2253
2254 #[tokio::test]
2255 async fn test_file_producer_filename_literal_without_expression() {
2256 use tower::ServiceExt;
2257
2258 let dir = tempfile::tempdir().unwrap();
2259 let dir_path = dir.path().to_str().unwrap();
2260
2261 let component = FileComponent::new();
2262 let ctx = NoOpComponentContext;
2263 let endpoint = component
2264 .create_endpoint(&format!("file:{dir_path}?fileName=plain.txt"), &ctx)
2265 .unwrap();
2266 let ctx = test_producer_ctx();
2267 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2268
2269 let exchange = Exchange::new(Message::new("data"));
2270 producer.oneshot(exchange).await.unwrap();
2271
2272 assert!(
2273 dir.path().join("plain.txt").exists(),
2274 "literal fileName without expressions should still work"
2275 );
2276 }
2277
2278 #[test]
2283 fn test_rejects_path_traversal_in_move_to() {
2284 let result = FileConfig::from_uri("file:/tmp/inbox?move=../etc/passwd");
2285 assert!(result.is_err(), "should reject path traversal in move_to");
2286 let err = result.unwrap_err().to_string();
2287 assert!(
2288 err.contains("path traversal"),
2289 "error should mention path traversal: {err}"
2290 );
2291 }
2292
2293 #[test]
2294 fn test_rejects_absolute_move_to() {
2295 let result = FileConfig::from_uri("file:/tmp/inbox?move=/tmp/outside");
2296 assert!(result.is_err(), "should reject absolute move_to");
2297 let err = result.unwrap_err().to_string();
2298 assert!(
2299 err.contains("relative path") || err.contains("Invalid URI"),
2300 "error should mention invalid move_to path: {err}"
2301 );
2302 }
2303
2304 #[test]
2305 fn test_rejects_path_traversal_in_temp_prefix() {
2306 let result = FileConfig::from_uri("file:/tmp/inbox?tempPrefix=../tmp");
2307 assert!(
2308 result.is_err(),
2309 "should reject path traversal in temp_prefix"
2310 );
2311 let err = result.unwrap_err().to_string();
2312 assert!(
2313 err.contains("path traversal"),
2314 "error should mention path traversal: {err}"
2315 );
2316 }
2317
2318 #[test]
2319 fn test_rejects_temp_prefix_with_path_separator() {
2320 let result = FileConfig::from_uri("file:/tmp/inbox?tempPrefix=tmp/sub");
2321 assert!(result.is_err(), "should reject temp_prefix with separator");
2322 let err = result.unwrap_err().to_string();
2323 assert!(
2324 err.contains("plain filename prefix"),
2325 "error should mention plain filename prefix restriction: {err}"
2326 );
2327 }
2328
2329 #[test]
2330 fn test_rejects_absolute_temp_prefix() {
2331 let result = FileConfig::from_uri("file:/tmp/inbox?tempPrefix=/tmp/");
2332 assert!(result.is_err(), "should reject absolute temp_prefix");
2333 let err = result.unwrap_err().to_string();
2334 assert!(
2335 err.contains("plain filename prefix"),
2336 "error should mention plain filename prefix restriction: {err}"
2337 );
2338 }
2339
2340 #[test]
2341 fn test_rejects_null_byte_in_temp_prefix() {
2342 let config = FileConfig {
2343 directory: "/tmp/inbox".into(),
2344 delay: Duration::from_millis(500),
2345 delay_ms: 500,
2346 initial_delay: Duration::from_millis(1000),
2347 initial_delay_ms: 1000,
2348 noop: false,
2349 delete: false,
2350 move_to: None,
2351 file_name: Some("ok.txt".into()),
2352 include: None,
2353 exclude: None,
2354 recursive: false,
2355 file_exist: FileExistStrategy::Override,
2356 read_lock_strategy: ReadLockStrategy::None,
2357 idempotent_key: IdempotentKey::None,
2358 done_file_name: None,
2359 charset: None,
2360 temp_prefix: Some("tmp\0".into()),
2361 auto_create: true,
2362 starting_directory_must_exist: false,
2363 read_timeout: Duration::from_millis(30_000),
2364 read_timeout_ms: 30_000,
2365 write_timeout: Duration::from_millis(30_000),
2366 write_timeout_ms: 30_000,
2367 };
2368
2369 let result = config.validate();
2370 assert!(result.is_err(), "should reject null byte in temp_prefix");
2371 }
2372
2373 #[test]
2374 fn test_rejects_null_byte_in_filename() {
2375 let config = FileConfig {
2378 directory: "/tmp/inbox".into(),
2379 delay: Duration::from_millis(500),
2380 delay_ms: 500,
2381 initial_delay: Duration::from_millis(1000),
2382 initial_delay_ms: 1000,
2383 noop: false,
2384 delete: false,
2385 move_to: None,
2386 file_name: Some("foo\0bar".into()),
2387 include: None,
2388 exclude: None,
2389 recursive: false,
2390 file_exist: FileExistStrategy::Override,
2391 read_lock_strategy: ReadLockStrategy::None,
2392 idempotent_key: IdempotentKey::None,
2393 done_file_name: None,
2394 charset: None,
2395 temp_prefix: None,
2396 auto_create: true,
2397 starting_directory_must_exist: false,
2398 read_timeout: Duration::from_millis(30_000),
2399 read_timeout_ms: 30_000,
2400 write_timeout: Duration::from_millis(30_000),
2401 write_timeout_ms: 30_000,
2402 };
2403 let result = config.validate();
2404 assert!(result.is_err(), "should reject null byte in filename");
2405 let err = result.unwrap_err().to_string();
2406 assert!(
2407 err.contains("null"),
2408 "error should mention null bytes: {err}"
2409 );
2410 }
2411
2412 #[test]
2413 fn test_rejects_empty_filename() {
2414 let config = FileConfig {
2415 directory: "/tmp/inbox".into(),
2416 delay: Duration::from_millis(500),
2417 delay_ms: 500,
2418 initial_delay: Duration::from_millis(1000),
2419 initial_delay_ms: 1000,
2420 noop: false,
2421 delete: false,
2422 move_to: None,
2423 file_name: Some("".into()),
2424 include: None,
2425 exclude: None,
2426 recursive: false,
2427 file_exist: FileExistStrategy::Override,
2428 read_lock_strategy: ReadLockStrategy::None,
2429 idempotent_key: IdempotentKey::None,
2430 done_file_name: None,
2431 charset: None,
2432 temp_prefix: None,
2433 auto_create: true,
2434 starting_directory_must_exist: false,
2435 read_timeout: Duration::from_millis(30_000),
2436 read_timeout_ms: 30_000,
2437 write_timeout: Duration::from_millis(30_000),
2438 write_timeout_ms: 30_000,
2439 };
2440 let result = config.validate();
2441 assert!(result.is_err(), "should reject empty filename");
2442 let err = result.unwrap_err().to_string();
2443 assert!(
2444 err.contains("empty") || err.contains("must not"),
2445 "error should mention empty: {err}"
2446 );
2447 }
2448
2449 #[test]
2450 fn test_rejects_nonexistent_directory_when_starting_directory_must_exist() {
2451 let result =
2452 FileConfig::from_uri("file:/tmp/nonexistent_dir_12345?startingDirectoryMustExist=true");
2453 assert!(
2454 result.is_err(),
2455 "should reject non-existent directory when startingDirectoryMustExist=true"
2456 );
2457 let err = result.unwrap_err().to_string();
2458 assert!(
2459 err.contains("does not exist"),
2460 "error should mention directory does not exist: {err}"
2461 );
2462 }
2463
2464 #[test]
2465 fn test_accepts_existing_directory_when_starting_directory_must_exist() {
2466 let dir = tempfile::tempdir().unwrap();
2467 let dir_path = dir.path().to_str().unwrap();
2468 let result =
2469 FileConfig::from_uri(&format!("file:{dir_path}?startingDirectoryMustExist=true"));
2470 assert!(
2471 result.is_ok(),
2472 "should accept existing directory when startingDirectoryMustExist=true: {:?}",
2473 result.err()
2474 );
2475 }
2476
2477 #[test]
2478 fn test_valid_config_passes() {
2479 let cfg = FileConfig::from_uri("file:/tmp/inbox").unwrap();
2480 assert!(cfg.validate().is_ok());
2481 }
2482
2483 #[test]
2484 fn test_file_exist_strategy_rejects_unknown() {
2485 let result = FileExistStrategy::from_str("BogusValue");
2487 assert!(
2488 result.is_err(),
2489 "unknown FileExistStrategy should return Err"
2490 );
2491 }
2492
2493 #[test]
2494 fn test_path_contains_traversal_detects_parent_dir() {
2495 assert!(path_contains_traversal("../etc/passwd"));
2496 assert!(path_contains_traversal("foo/../bar"));
2497 assert!(path_contains_traversal(".."));
2498 assert!(path_contains_traversal("a/b/../../../c"));
2499 }
2500
2501 #[test]
2502 fn test_path_contains_traversal_accepts_safe_paths() {
2503 assert!(!path_contains_traversal("safe/path"));
2504 assert!(!path_contains_traversal("/absolute/path"));
2505 assert!(!path_contains_traversal("filename.txt"));
2506 assert!(!path_contains_traversal(".hidden"));
2507 assert!(!path_contains_traversal(""));
2508 }
2509
2510 #[tokio::test]
2515 async fn test_stream_detects_file_modified_during_read() {
2516 use futures::StreamExt;
2517
2518 let dir = tempfile::tempdir().unwrap();
2519 let file_path = dir.path().join("mutable.txt");
2520 std::fs::write(&file_path, b"initial content here").unwrap();
2521
2522 let initial_meta = std::fs::metadata(&file_path).unwrap();
2524 let initial_size = initial_meta.len();
2525 let initial_mtime = initial_meta.modified().ok();
2526
2527 let file = tokio::fs::File::open(&file_path).await.unwrap();
2529 let raw_stream = ReaderStream::new(file).map(|res| res.map_err(CamelError::from));
2530
2531 let mut stream = ModificationDetectingStream::new(
2532 raw_stream,
2533 file_path.clone(),
2534 initial_size,
2535 initial_mtime,
2536 );
2537
2538 let chunk = stream.next().await;
2540 assert!(chunk.is_some(), "should produce at least one chunk");
2541 assert!(chunk.as_ref().unwrap().is_ok(), "first chunk should be Ok");
2542
2543 std::fs::write(&file_path, b"modified content - different size!!").unwrap();
2545
2546 let mut got_error = false;
2548 while let Some(result) = stream.next().await {
2549 if result.is_err() {
2550 let err = result.unwrap_err();
2551 let msg = err.to_string();
2552 assert!(
2553 msg.contains("file modified during read"),
2554 "expected modification error, got: {msg}"
2555 );
2556 got_error = true;
2557 break;
2558 }
2559 }
2560
2561 assert!(
2562 got_error,
2563 "stream should detect file was modified during read"
2564 );
2565 }
2566
2567 #[tokio::test]
2568 async fn test_stream_succeeds_when_file_not_modified() {
2569 use futures::StreamExt;
2570
2571 let dir = tempfile::tempdir().unwrap();
2572 let file_path = dir.path().join("stable.txt");
2573 std::fs::write(&file_path, b"stable content").unwrap();
2574
2575 let initial_meta = std::fs::metadata(&file_path).unwrap();
2576 let initial_size = initial_meta.len();
2577 let initial_mtime = initial_meta.modified().ok();
2578
2579 let file = tokio::fs::File::open(&file_path).await.unwrap();
2580 let raw_stream = ReaderStream::new(file).map(|res| res.map_err(CamelError::from));
2581
2582 let mut stream = ModificationDetectingStream::new(
2583 raw_stream,
2584 file_path.clone(),
2585 initial_size,
2586 initial_mtime,
2587 );
2588
2589 let mut all_ok = true;
2591 while let Some(result) = stream.next().await {
2592 if result.is_err() {
2593 all_ok = false;
2594 break;
2595 }
2596 }
2597
2598 assert!(
2599 all_ok,
2600 "stream should complete without errors when file is not modified"
2601 );
2602 }
2603}