1pub mod bundle;
8
9pub use bundle::FileBundle;
10
11use std::collections::HashSet;
12use std::future::Future;
13use std::path::PathBuf;
14use std::pin::Pin;
15use std::str::FromStr;
16use std::task::{Context, Poll};
17use std::time::Duration;
18
19use async_trait::async_trait;
20use futures::StreamExt;
21use regex::Regex;
22use tokio::fs;
23use tokio::fs::OpenOptions;
24use tokio::io;
25use tokio::io::AsyncWriteExt;
26use tokio::time;
27use tokio_util::io::ReaderStream;
28use tower::Service;
29use tracing::{debug, warn};
30
31use camel_component_api::{
32 Body, BoxProcessor, CamelError, Exchange, Message, StreamBody, StreamMetadata,
33};
34use camel_component_api::{Component, Consumer, ConsumerContext, Endpoint, ProducerContext};
35use camel_component_api::{UriConfig, parse_uri};
36use camel_language_api::Language;
37use camel_language_simple::SimpleLanguage;
38
39struct TempFileGuard {
48 path: PathBuf,
49 disarm: bool,
50}
51
52impl TempFileGuard {
53 fn new(path: PathBuf) -> Self {
54 Self {
55 path,
56 disarm: false,
57 }
58 }
59
60 fn disarm(&mut self) {
62 self.disarm = true;
63 }
64}
65
66impl Drop for TempFileGuard {
67 fn drop(&mut self) {
68 if !self.disarm {
69 let _ = std::fs::remove_file(&self.path);
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
81pub enum FileExistStrategy {
82 #[default]
84 Override,
85 Append,
87 Fail,
89}
90
91impl FromStr for FileExistStrategy {
92 type Err = String;
93
94 fn from_str(s: &str) -> Result<Self, Self::Err> {
95 match s {
96 "Override" | "override" => Ok(FileExistStrategy::Override),
97 "Append" | "append" => Ok(FileExistStrategy::Append),
98 "Fail" | "fail" => Ok(FileExistStrategy::Fail),
99 _ => Ok(FileExistStrategy::Override), }
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
112#[serde(default)]
113pub struct FileGlobalConfig {
114 pub delay_ms: u64,
115 pub initial_delay_ms: u64,
116 pub read_timeout_ms: u64,
117 pub write_timeout_ms: u64,
118}
119
120impl Default for FileGlobalConfig {
121 fn default() -> Self {
122 Self {
123 delay_ms: 500,
124 initial_delay_ms: 1_000,
125 read_timeout_ms: 30_000,
126 write_timeout_ms: 30_000,
127 }
128 }
129}
130
131impl FileGlobalConfig {
132 pub fn new() -> Self {
133 Self::default()
134 }
135 pub fn with_delay_ms(mut self, v: u64) -> Self {
136 self.delay_ms = v;
137 self
138 }
139 pub fn with_initial_delay_ms(mut self, v: u64) -> Self {
140 self.initial_delay_ms = v;
141 self
142 }
143 pub fn with_read_timeout_ms(mut self, v: u64) -> Self {
144 self.read_timeout_ms = v;
145 self
146 }
147 pub fn with_write_timeout_ms(mut self, v: u64) -> Self {
148 self.write_timeout_ms = v;
149 self
150 }
151}
152
153#[derive(Debug, Clone, UriConfig)]
180#[uri_scheme = "file"]
181#[uri_config(skip_impl, crate = "camel_component_api")]
182pub struct FileConfig {
183 pub directory: String,
185
186 #[allow(dead_code)]
188 #[uri_param(name = "delay", default = "500")]
189 delay_ms: u64,
190
191 pub delay: Duration,
193
194 #[allow(dead_code)]
196 #[uri_param(name = "initialDelay", default = "1000")]
197 initial_delay_ms: u64,
198
199 pub initial_delay: Duration,
201
202 #[uri_param(default = "false")]
204 pub noop: bool,
205
206 #[uri_param(default = "false")]
208 pub delete: bool,
209
210 #[uri_param(name = "move")]
213 move_to: Option<String>,
214
215 #[uri_param(name = "fileName")]
217 pub file_name: Option<String>,
218
219 #[uri_param]
221 pub include: Option<String>,
222
223 #[uri_param]
225 pub exclude: Option<String>,
226
227 #[uri_param(default = "false")]
229 pub recursive: bool,
230
231 #[uri_param(name = "fileExist", default = "Override")]
233 pub file_exist: FileExistStrategy,
234
235 #[uri_param(name = "tempPrefix")]
237 pub temp_prefix: Option<String>,
238
239 #[uri_param(name = "autoCreate", default = "true")]
241 pub auto_create: bool,
242
243 #[allow(dead_code)]
245 #[uri_param(name = "readTimeout", default = "30000")]
246 read_timeout_ms: u64,
247
248 pub read_timeout: Duration,
250
251 #[allow(dead_code)]
253 #[uri_param(name = "writeTimeout", default = "30000")]
254 write_timeout_ms: u64,
255
256 pub write_timeout: Duration,
258}
259
260impl UriConfig for FileConfig {
261 fn scheme() -> &'static str {
262 "file"
263 }
264
265 fn from_uri(uri: &str) -> Result<Self, CamelError> {
266 let parts = parse_uri(uri)?;
267 Self::from_components(parts)
268 }
269
270 fn from_components(parts: camel_component_api::UriComponents) -> Result<Self, CamelError> {
271 Self::parse_uri_components(parts)?.validate()
272 }
273
274 fn validate(self) -> Result<Self, CamelError> {
275 let move_to = if self.noop || self.delete {
279 None
280 } else {
281 Some(self.move_to.unwrap_or_else(|| ".camel".to_string()))
282 };
283
284 Ok(Self { move_to, ..self })
285 }
286}
287
288impl FileConfig {
289 pub fn apply_global_defaults(&mut self, global: &FileGlobalConfig) {
297 if self.delay == Duration::from_millis(500) {
298 self.delay = Duration::from_millis(global.delay_ms);
299 }
300 if self.initial_delay == Duration::from_millis(1_000) {
301 self.initial_delay = Duration::from_millis(global.initial_delay_ms);
302 }
303 if self.read_timeout == Duration::from_millis(30_000) {
304 self.read_timeout = Duration::from_millis(global.read_timeout_ms);
305 }
306 if self.write_timeout == Duration::from_millis(30_000) {
307 self.write_timeout = Duration::from_millis(global.write_timeout_ms);
308 }
309 }
310}
311
312pub struct FileComponent {
317 config: Option<FileGlobalConfig>,
318}
319
320impl FileComponent {
321 pub fn new() -> Self {
322 Self { config: None }
323 }
324
325 pub fn with_config(config: FileGlobalConfig) -> Self {
326 Self {
327 config: Some(config),
328 }
329 }
330
331 pub fn with_optional_config(config: Option<FileGlobalConfig>) -> Self {
332 Self { config }
333 }
334}
335
336impl Default for FileComponent {
337 fn default() -> Self {
338 Self::new()
339 }
340}
341
342impl Component for FileComponent {
343 fn scheme(&self) -> &str {
344 "file"
345 }
346
347 fn create_endpoint(
348 &self,
349 uri: &str,
350 _ctx: &dyn camel_component_api::ComponentContext,
351 ) -> Result<Box<dyn Endpoint>, CamelError> {
352 let mut config = FileConfig::from_uri(uri)?;
353 if let Some(ref global_config) = self.config {
354 config.apply_global_defaults(global_config);
355 }
356 Ok(Box::new(FileEndpoint {
357 uri: uri.to_string(),
358 config,
359 }))
360 }
361}
362
363struct FileEndpoint {
368 uri: String,
369 config: FileConfig,
370}
371
372impl Endpoint for FileEndpoint {
373 fn uri(&self) -> &str {
374 &self.uri
375 }
376
377 fn create_consumer(&self) -> Result<Box<dyn Consumer>, CamelError> {
378 Ok(Box::new(FileConsumer {
379 config: self.config.clone(),
380 seen: HashSet::new(),
381 }))
382 }
383
384 fn create_producer(&self, _ctx: &ProducerContext) -> Result<BoxProcessor, CamelError> {
385 Ok(BoxProcessor::new(FileProducer {
386 config: self.config.clone(),
387 }))
388 }
389}
390
391struct FileConsumer {
396 config: FileConfig,
397 seen: HashSet<PathBuf>,
398}
399
400#[async_trait]
401impl Consumer for FileConsumer {
402 async fn start(&mut self, context: ConsumerContext) -> Result<(), CamelError> {
403 let config = self.config.clone();
404
405 let include_re = config
406 .include
407 .as_ref()
408 .map(|p| Regex::new(p))
409 .transpose()
410 .map_err(|e| CamelError::InvalidUri(format!("invalid include regex: {e}")))?;
411 let exclude_re = config
412 .exclude
413 .as_ref()
414 .map(|p| Regex::new(p))
415 .transpose()
416 .map_err(|e| CamelError::InvalidUri(format!("invalid exclude regex: {e}")))?;
417
418 if !config.initial_delay.is_zero() {
419 tokio::select! {
420 _ = time::sleep(config.initial_delay) => {}
421 _ = context.cancelled() => {
422 debug!(directory = config.directory, "File consumer cancelled during initial delay");
423 return Ok(());
424 }
425 }
426 }
427
428 let mut interval = time::interval(config.delay);
429
430 loop {
431 tokio::select! {
432 _ = context.cancelled() => {
433 debug!(directory = config.directory, "File consumer received cancellation, stopping");
434 break;
435 }
436 _ = interval.tick() => {
437 if let Err(e) = poll_directory(
438 &config,
439 &context,
440 &include_re,
441 &exclude_re,
442 &mut self.seen,
443 ).await {
444 warn!(directory = config.directory, error = %e, "Error polling directory");
445 }
446 }
447 }
448 }
449
450 Ok(())
451 }
452
453 async fn stop(&mut self) -> Result<(), CamelError> {
454 Ok(())
455 }
456}
457
458async fn poll_directory(
459 config: &FileConfig,
460 context: &ConsumerContext,
461 include_re: &Option<Regex>,
462 exclude_re: &Option<Regex>,
463 seen: &mut HashSet<PathBuf>,
464) -> Result<(), CamelError> {
465 let base_path = std::path::Path::new(&config.directory);
466
467 let files = list_files(base_path, config.recursive).await?;
468
469 for file_path in files {
470 let file_name = file_path
471 .file_name()
472 .and_then(|n| n.to_str())
473 .unwrap_or_default()
474 .to_string();
475
476 if let Some(ref target_name) = config.file_name
477 && file_name != *target_name
478 {
479 continue;
480 }
481
482 if let Some(re) = include_re
483 && !re.is_match(&file_name)
484 {
485 continue;
486 }
487
488 if let Some(re) = exclude_re
489 && re.is_match(&file_name)
490 {
491 continue;
492 }
493
494 if let Some(ref move_dir) = config.move_to
495 && file_path.starts_with(base_path.join(move_dir))
496 {
497 continue;
498 }
499
500 if config.noop && seen.contains(&file_path) {
502 continue;
503 }
504
505 let (file, metadata) = match tokio::time::timeout(config.read_timeout, async {
506 let f = fs::File::open(&file_path).await?;
507 let m = f.metadata().await?;
508 Ok::<_, std::io::Error>((f, m))
509 })
510 .await
511 {
512 Ok(Ok((f, m))) => (f, Some(m)),
513 Ok(Err(e)) => {
514 warn!(
515 file = %file_path.display(),
516 error = %e,
517 "Failed to open file"
518 );
519 continue;
520 }
521 Err(_) => {
522 warn!(
523 file = %file_path.display(),
524 timeout_ms = config.read_timeout.as_millis(),
525 "Timeout opening file"
526 );
527 continue;
528 }
529 };
530
531 let file_len = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
532 let stream = ReaderStream::new(file).map(|res| res.map_err(CamelError::from));
533
534 let last_modified = metadata
535 .as_ref()
536 .and_then(|m| m.modified().ok())
537 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
538 .map(|d| d.as_millis() as u64)
539 .unwrap_or(0);
540
541 let relative_path = file_path
542 .strip_prefix(base_path)
543 .unwrap_or(&file_path)
544 .to_string_lossy()
545 .to_string();
546
547 let absolute_path = file_path
548 .canonicalize()
549 .unwrap_or_else(|_| file_path.clone())
550 .to_string_lossy()
551 .to_string();
552
553 let body = Body::Stream(StreamBody {
554 stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
555 metadata: StreamMetadata {
556 size_hint: Some(file_len),
557 content_type: None,
558 origin: Some(absolute_path.clone()),
559 },
560 });
561
562 let mut exchange = Exchange::new(Message::new(body));
563 exchange
564 .input
565 .set_header("CamelFileName", serde_json::Value::String(relative_path));
566 exchange.input.set_header(
567 "CamelFileNameOnly",
568 serde_json::Value::String(file_name.clone()),
569 );
570 exchange.input.set_header(
571 "CamelFileAbsolutePath",
572 serde_json::Value::String(absolute_path),
573 );
574 exchange.input.set_header(
575 "CamelFileLength",
576 serde_json::Value::Number(file_len.into()),
577 );
578 exchange.input.set_header(
579 "CamelFileLastModified",
580 serde_json::Value::Number(last_modified.into()),
581 );
582
583 debug!(
584 file = %file_path.display(),
585 correlation_id = %exchange.correlation_id(),
586 "Processing file"
587 );
588
589 if context.send(exchange).await.is_err() {
590 break;
591 }
592
593 if config.noop {
594 seen.insert(file_path.clone());
595 }
596
597 if config.noop {
598 } else if config.delete {
600 if let Err(e) = fs::remove_file(&file_path).await {
601 warn!(file = %file_path.display(), error = %e, "Failed to delete file");
602 }
603 } else if let Some(ref move_dir) = config.move_to {
604 let target_dir = base_path.join(move_dir);
605 if let Err(e) = fs::create_dir_all(&target_dir).await {
606 warn!(dir = %target_dir.display(), error = %e, "Failed to create move directory");
607 continue;
608 }
609 let target_path = target_dir.join(&file_name);
610 if let Err(e) = fs::rename(&file_path, &target_path).await {
611 warn!(
612 from = %file_path.display(),
613 to = %target_path.display(),
614 error = %e,
615 "Failed to move file"
616 );
617 }
618 }
619 }
620
621 Ok(())
622}
623
624async fn list_files(
625 dir: &std::path::Path,
626 recursive: bool,
627) -> Result<Vec<std::path::PathBuf>, CamelError> {
628 let mut files = Vec::new();
629 let mut read_dir = fs::read_dir(dir).await.map_err(CamelError::from)?;
630
631 while let Some(entry) = read_dir.next_entry().await.map_err(CamelError::from)? {
632 let path = entry.path();
633 if path.is_file() {
634 files.push(path);
635 } else if path.is_dir() && recursive {
636 let mut sub_files = Box::pin(list_files(&path, true)).await?;
637 files.append(&mut sub_files);
638 }
639 }
640
641 files.sort();
642 Ok(files)
643}
644
645fn validate_path_is_within_base(
650 base_dir: &std::path::Path,
651 target_path: &std::path::Path,
652) -> Result<(), CamelError> {
653 let canonical_base = base_dir.canonicalize().map_err(|e| {
654 CamelError::ProcessorError(format!("Cannot canonicalize base directory: {}", e))
655 })?;
656
657 let canonical_target = if target_path.exists() {
659 target_path.canonicalize().map_err(|e| {
660 CamelError::ProcessorError(format!("Cannot canonicalize target path: {}", e))
661 })?
662 } else if let Some(parent) = target_path.parent() {
663 if !parent.exists() {
665 return Err(CamelError::ProcessorError(format!(
666 "Parent directory '{}' does not exist",
667 parent.display()
668 )));
669 }
670 let canonical_parent = parent.canonicalize().map_err(|e| {
671 CamelError::ProcessorError(format!("Cannot canonicalize parent directory: {}", e))
672 })?;
673 if let Some(filename) = target_path.file_name() {
675 canonical_parent.join(filename)
676 } else {
677 return Err(CamelError::ProcessorError(
678 "Invalid target path: no filename".to_string(),
679 ));
680 }
681 } else {
682 return Err(CamelError::ProcessorError(
683 "Invalid target path: no parent directory".to_string(),
684 ));
685 };
686
687 if !canonical_target.starts_with(&canonical_base) {
688 return Err(CamelError::ProcessorError(format!(
689 "Path '{}' is outside base directory '{}'",
690 canonical_target.display(),
691 canonical_base.display()
692 )));
693 }
694
695 Ok(())
696}
697
698#[derive(Clone)]
703struct FileProducer {
704 config: FileConfig,
705}
706
707impl FileProducer {
708 fn resolve_filename(exchange: &Exchange, config: &FileConfig) -> Result<String, CamelError> {
709 let raw = if let Some(name) = exchange
710 .input
711 .header("CamelFileName")
712 .and_then(|v| v.as_str())
713 {
714 Some(name.to_string())
715 } else {
716 config.file_name.clone()
717 };
718
719 match raw {
720 Some(name) if name.contains("${") => {
721 let lang = SimpleLanguage::new();
722 let expr = lang.create_expression(&name).map_err(|e| {
723 CamelError::ProcessorError(format!(
724 "cannot parse fileName expression '{}': {e}",
725 name
726 ))
727 })?;
728 let val = expr.evaluate(exchange).map_err(|e| {
729 CamelError::ProcessorError(format!(
730 "cannot evaluate fileName expression '{}': {e}",
731 name
732 ))
733 })?;
734 match val {
735 serde_json::Value::String(s) => Ok(s),
736 other => Ok(other.to_string()),
737 }
738 }
739 Some(name) => Ok(name),
740 None => Err(CamelError::ProcessorError(
741 "No filename specified: set CamelFileName header or fileName option".to_string(),
742 )),
743 }
744 }
745}
746
747impl Service<Exchange> for FileProducer {
748 type Response = Exchange;
749 type Error = CamelError;
750 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
751
752 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
753 Poll::Ready(Ok(()))
754 }
755
756 fn call(&mut self, mut exchange: Exchange) -> Self::Future {
757 let config = self.config.clone();
758
759 Box::pin(async move {
760 let file_name = FileProducer::resolve_filename(&exchange, &config)?;
761 let body = exchange.input.body.clone();
762
763 let dir_path = std::path::Path::new(&config.directory);
764 let target_path = dir_path.join(&file_name);
765
766 if config.auto_create
768 && let Some(parent) = target_path.parent()
769 {
770 tokio::time::timeout(config.write_timeout, fs::create_dir_all(parent))
771 .await
772 .map_err(|_| CamelError::ProcessorError("Timeout creating directories".into()))?
773 .map_err(CamelError::from)?;
774 }
775
776 validate_path_is_within_base(dir_path, &target_path)?;
778
779 match config.file_exist {
781 FileExistStrategy::Fail if target_path.exists() => {
782 return Err(CamelError::ProcessorError(format!(
783 "File already exists: {}",
784 target_path.display()
785 )));
786 }
787 FileExistStrategy::Append => {
788 let mut file = tokio::time::timeout(
790 config.write_timeout,
791 OpenOptions::new()
792 .append(true)
793 .create(true)
794 .open(&target_path),
795 )
796 .await
797 .map_err(|_| {
798 CamelError::ProcessorError("Timeout opening file for append".into())
799 })?
800 .map_err(CamelError::from)?;
801
802 tokio::time::timeout(
803 config.write_timeout,
804 io::copy(&mut body.into_async_read(), &mut file),
805 )
806 .await
807 .map_err(|_| CamelError::ProcessorError("Timeout writing to file".into()))?
808 .map_err(|e| CamelError::ProcessorError(e.to_string()))?;
809
810 file.flush().await.map_err(CamelError::from)?;
811 }
812 _ => {
813 let temp_name = if let Some(ref prefix) = config.temp_prefix {
815 format!("{prefix}{file_name}")
816 } else {
817 format!(".tmp.{file_name}")
818 };
819 let temp_path = dir_path.join(&temp_name);
820
821 let mut guard = TempFileGuard::new(temp_path.clone());
823
824 let mut file =
826 tokio::time::timeout(config.write_timeout, fs::File::create(&temp_path))
827 .await
828 .map_err(|_| {
829 CamelError::ProcessorError("Timeout creating temp file".into())
830 })?
831 .map_err(CamelError::from)?;
832
833 let copy_result = tokio::time::timeout(
834 config.write_timeout,
835 io::copy(&mut body.into_async_read(), &mut file),
836 )
837 .await;
838
839 let _ = file.flush().await;
841
842 match copy_result {
843 Ok(Ok(_)) => {}
844 Ok(Err(e)) => {
845 return Err(CamelError::ProcessorError(e.to_string()));
847 }
848 Err(_) => {
849 return Err(CamelError::ProcessorError("Timeout writing file".into()));
851 }
852 }
853
854 let rename_result = tokio::time::timeout(
856 config.write_timeout,
857 fs::rename(&temp_path, &target_path),
858 )
859 .await;
860
861 match rename_result {
862 Ok(Ok(_)) => {
863 guard.disarm();
865 }
866 Ok(Err(e)) => {
867 return Err(CamelError::from(e));
869 }
870 Err(_) => {
871 return Err(CamelError::ProcessorError("Timeout renaming file".into()));
873 }
874 }
875 }
876 }
877
878 let abs_path = target_path
880 .canonicalize()
881 .unwrap_or_else(|_| target_path.clone())
882 .to_string_lossy()
883 .to_string();
884 exchange
885 .input
886 .set_header("CamelFileNameProduced", serde_json::Value::String(abs_path));
887
888 debug!(
889 file = %target_path.display(),
890 correlation_id = %exchange.correlation_id(),
891 "File written"
892 );
893
894 Ok(exchange)
895 })
896 }
897}
898
899#[cfg(test)]
900mod tests {
901 use super::*;
902 use bytes::Bytes;
903 use camel_component_api::NoOpComponentContext;
904 use std::time::Duration;
905 use tokio_util::sync::CancellationToken;
906
907 fn test_producer_ctx() -> ProducerContext {
908 ProducerContext::new()
909 }
910
911 #[test]
912 fn test_file_config_defaults() {
913 let config = FileConfig::from_uri("file:/tmp/inbox").unwrap();
914 assert_eq!(config.directory, "/tmp/inbox");
915 assert_eq!(config.delay, Duration::from_millis(500));
916 assert_eq!(config.initial_delay, Duration::from_millis(1000));
917 assert!(!config.noop);
918 assert!(!config.delete);
919 assert_eq!(config.move_to, Some(".camel".to_string()));
920 assert!(config.file_name.is_none());
921 assert!(config.include.is_none());
922 assert!(config.exclude.is_none());
923 assert!(!config.recursive);
924 assert_eq!(config.file_exist, FileExistStrategy::Override);
925 assert!(config.temp_prefix.is_none());
926 assert!(config.auto_create);
927 assert_eq!(config.read_timeout, Duration::from_secs(30));
929 assert_eq!(config.write_timeout, Duration::from_secs(30));
930 }
931
932 #[test]
933 fn test_file_config_consumer_options() {
934 let config = FileConfig::from_uri(
935 "file:/data/input?delay=1000&initialDelay=2000&noop=true&recursive=true&include=.*\\.csv"
936 ).unwrap();
937 assert_eq!(config.directory, "/data/input");
938 assert_eq!(config.delay, Duration::from_millis(1000));
939 assert_eq!(config.initial_delay, Duration::from_millis(2000));
940 assert!(config.noop);
941 assert!(config.recursive);
942 assert_eq!(config.include, Some(".*\\.csv".to_string()));
943 }
944
945 #[test]
946 fn test_file_config_producer_options() {
947 let config = FileConfig::from_uri(
948 "file:/data/output?fileExist=Append&tempPrefix=.tmp&autoCreate=false&fileName=out.txt",
949 )
950 .unwrap();
951 assert_eq!(config.file_exist, FileExistStrategy::Append);
952 assert_eq!(config.temp_prefix, Some(".tmp".to_string()));
953 assert!(!config.auto_create);
954 assert_eq!(config.file_name, Some("out.txt".to_string()));
955 }
956
957 #[test]
958 fn test_file_config_delete_mode() {
959 let config = FileConfig::from_uri("file:/tmp/inbox?delete=true").unwrap();
960 assert!(config.delete);
961 assert!(config.move_to.is_none());
962 }
963
964 #[test]
965 fn test_file_config_noop_mode() {
966 let config = FileConfig::from_uri("file:/tmp/inbox?noop=true").unwrap();
967 assert!(config.noop);
968 assert!(config.move_to.is_none());
969 }
970
971 #[test]
972 fn test_file_config_wrong_scheme() {
973 let result = FileConfig::from_uri("timer:tick");
974 assert!(result.is_err());
975 }
976
977 #[test]
978 fn test_file_component_scheme() {
979 let component = FileComponent::new();
980 assert_eq!(component.scheme(), "file");
981 }
982
983 #[test]
984 fn test_file_component_creates_endpoint() {
985 let component = FileComponent::new();
986 let ctx = NoOpComponentContext;
987 let endpoint = component.create_endpoint("file:/tmp/test", &ctx);
988 assert!(endpoint.is_ok());
989 }
990
991 #[tokio::test]
996 async fn test_file_consumer_reads_files() {
997 let dir = tempfile::tempdir().unwrap();
998 let dir_path = dir.path().to_str().unwrap();
999
1000 std::fs::write(dir.path().join("test1.txt"), "hello").unwrap();
1001 std::fs::write(dir.path().join("test2.txt"), "world").unwrap();
1002
1003 let component = FileComponent::new();
1004 let ctx = NoOpComponentContext;
1005 let endpoint = component
1006 .create_endpoint(
1007 &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100"),
1008 &ctx,
1009 )
1010 .unwrap();
1011 let mut consumer = endpoint.create_consumer().unwrap();
1012
1013 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1014 let token = CancellationToken::new();
1015 let ctx = ConsumerContext::new(tx, token.clone());
1016
1017 tokio::spawn(async move {
1018 consumer.start(ctx).await.unwrap();
1019 });
1020
1021 let mut received = Vec::new();
1022 let timeout = tokio::time::timeout(Duration::from_secs(2), async {
1023 while let Some(envelope) = rx.recv().await {
1024 received.push(envelope.exchange);
1025 if received.len() == 2 {
1026 break;
1027 }
1028 }
1029 })
1030 .await;
1031 token.cancel();
1032
1033 assert!(timeout.is_ok(), "Should have received 2 exchanges");
1034 assert_eq!(received.len(), 2);
1035
1036 for ex in &received {
1037 assert!(ex.input.header("CamelFileName").is_some());
1038 assert!(ex.input.header("CamelFileNameOnly").is_some());
1039 assert!(ex.input.header("CamelFileAbsolutePath").is_some());
1040 assert!(ex.input.header("CamelFileLength").is_some());
1041 assert!(ex.input.header("CamelFileLastModified").is_some());
1042 }
1043 }
1044
1045 #[tokio::test]
1046 async fn noop_second_poll_does_not_re_emit_seen_files() {
1047 let dir = tempfile::tempdir().unwrap();
1048 let file_path = dir.path().join("test.txt");
1049 tokio::fs::write(&file_path, b"hello").await.unwrap();
1050
1051 let uri = format!(
1052 "file:{}?noop=true&initialDelay=0&delay=50",
1053 dir.path().display()
1054 );
1055 let config = FileConfig::from_uri(&uri).unwrap();
1056 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1057 let token = CancellationToken::new();
1058 let ctx = ConsumerContext::new(tx, token);
1059
1060 let include_re = None;
1061 let exclude_re = None;
1062 let mut seen = std::collections::HashSet::new();
1063
1064 poll_directory(&config, &ctx, &include_re, &exclude_re, &mut seen)
1065 .await
1066 .unwrap();
1067 assert!(rx.try_recv().is_ok(), "first poll should emit file");
1068 assert!(rx.try_recv().is_err(), "should only emit once");
1069
1070 poll_directory(&config, &ctx, &include_re, &exclude_re, &mut seen)
1071 .await
1072 .unwrap();
1073 assert!(
1074 rx.try_recv().is_err(),
1075 "second poll should not re-emit seen file"
1076 );
1077 }
1078
1079 #[tokio::test]
1080 async fn noop_new_files_picked_up_after_first_poll() {
1081 let dir = tempfile::tempdir().unwrap();
1082 let file1 = dir.path().join("a.txt");
1083 tokio::fs::write(&file1, b"a").await.unwrap();
1084
1085 let uri = format!(
1086 "file:{}?noop=true&initialDelay=0&delay=50",
1087 dir.path().display()
1088 );
1089 let config = FileConfig::from_uri(&uri).unwrap();
1090 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1091 let token = CancellationToken::new();
1092 let ctx = ConsumerContext::new(tx, token);
1093
1094 let include_re = None;
1095 let exclude_re = None;
1096 let mut seen = std::collections::HashSet::new();
1097
1098 poll_directory(&config, &ctx, &include_re, &exclude_re, &mut seen)
1099 .await
1100 .unwrap();
1101 let _ = rx.try_recv();
1102
1103 let file2 = dir.path().join("b.txt");
1104 tokio::fs::write(&file2, b"b").await.unwrap();
1105
1106 poll_directory(&config, &ctx, &include_re, &exclude_re, &mut seen)
1107 .await
1108 .unwrap();
1109 assert!(
1110 rx.try_recv().is_ok(),
1111 "b.txt should be emitted on second poll"
1112 );
1113 assert!(rx.try_recv().is_err(), "a.txt should not be re-emitted");
1114 }
1115
1116 #[tokio::test]
1117 async fn test_file_consumer_include_filter() {
1118 let dir = tempfile::tempdir().unwrap();
1119 let dir_path = dir.path().to_str().unwrap();
1120
1121 std::fs::write(dir.path().join("data.csv"), "a,b,c").unwrap();
1122 std::fs::write(dir.path().join("readme.txt"), "hello").unwrap();
1123
1124 let component = FileComponent::new();
1125 let ctx = NoOpComponentContext;
1126 let endpoint = component
1127 .create_endpoint(
1128 &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&include=.*\\.csv"),
1129 &ctx,
1130 )
1131 .unwrap();
1132 let mut consumer = endpoint.create_consumer().unwrap();
1133
1134 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1135 let token = CancellationToken::new();
1136 let ctx = ConsumerContext::new(tx, token.clone());
1137
1138 tokio::spawn(async move {
1139 consumer.start(ctx).await.unwrap();
1140 });
1141
1142 let mut received = Vec::new();
1143 let _ = tokio::time::timeout(Duration::from_millis(500), async {
1144 while let Some(envelope) = rx.recv().await {
1145 received.push(envelope.exchange);
1146 if received.len() == 1 {
1147 break;
1148 }
1149 }
1150 })
1151 .await;
1152 token.cancel();
1153
1154 assert_eq!(received.len(), 1);
1155 let name = received[0]
1156 .input
1157 .header("CamelFileNameOnly")
1158 .and_then(|v| v.as_str())
1159 .unwrap();
1160 assert_eq!(name, "data.csv");
1161 }
1162
1163 #[tokio::test]
1164 async fn test_file_consumer_delete_mode() {
1165 let dir = tempfile::tempdir().unwrap();
1166 let dir_path = dir.path().to_str().unwrap();
1167
1168 std::fs::write(dir.path().join("deleteme.txt"), "bye").unwrap();
1169
1170 let component = FileComponent::new();
1171 let ctx = NoOpComponentContext;
1172 let endpoint = component
1173 .create_endpoint(
1174 &format!("file:{dir_path}?delete=true&initialDelay=0&delay=100"),
1175 &ctx,
1176 )
1177 .unwrap();
1178 let mut consumer = endpoint.create_consumer().unwrap();
1179
1180 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1181 let token = CancellationToken::new();
1182 let ctx = ConsumerContext::new(tx, token.clone());
1183
1184 tokio::spawn(async move {
1185 consumer.start(ctx).await.unwrap();
1186 });
1187
1188 let _ = tokio::time::timeout(Duration::from_millis(500), async { rx.recv().await }).await;
1189 token.cancel();
1190
1191 tokio::time::sleep(Duration::from_millis(100)).await;
1192
1193 assert!(
1194 !dir.path().join("deleteme.txt").exists(),
1195 "File should be deleted"
1196 );
1197 }
1198
1199 #[tokio::test]
1200 async fn test_file_consumer_move_mode() {
1201 let dir = tempfile::tempdir().unwrap();
1202 let dir_path = dir.path().to_str().unwrap();
1203
1204 std::fs::write(dir.path().join("moveme.txt"), "data").unwrap();
1205
1206 let component = FileComponent::new();
1207 let ctx = NoOpComponentContext;
1208 let endpoint = component
1209 .create_endpoint(&format!("file:{dir_path}?initialDelay=0&delay=100"), &ctx)
1210 .unwrap();
1211 let mut consumer = endpoint.create_consumer().unwrap();
1212
1213 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1214 let token = CancellationToken::new();
1215 let ctx = ConsumerContext::new(tx, token.clone());
1216
1217 tokio::spawn(async move {
1218 consumer.start(ctx).await.unwrap();
1219 });
1220
1221 let _ = tokio::time::timeout(Duration::from_millis(500), async { rx.recv().await }).await;
1222 token.cancel();
1223
1224 tokio::time::sleep(Duration::from_millis(100)).await;
1225
1226 assert!(
1227 !dir.path().join("moveme.txt").exists(),
1228 "Original file should be gone"
1229 );
1230 assert!(
1231 dir.path().join(".camel").join("moveme.txt").exists(),
1232 "File should be in .camel/"
1233 );
1234 }
1235
1236 #[tokio::test]
1237 async fn test_file_consumer_respects_cancellation() {
1238 let dir = tempfile::tempdir().unwrap();
1239 let dir_path = dir.path().to_str().unwrap();
1240
1241 let component = FileComponent::new();
1242 let ctx = NoOpComponentContext;
1243 let endpoint = component
1244 .create_endpoint(&format!("file:{dir_path}?initialDelay=0&delay=50"), &ctx)
1245 .unwrap();
1246 let mut consumer = endpoint.create_consumer().unwrap();
1247
1248 let (tx, _rx) = tokio::sync::mpsc::channel(16);
1249 let token = CancellationToken::new();
1250 let ctx = ConsumerContext::new(tx, token.clone());
1251
1252 let handle = tokio::spawn(async move {
1253 consumer.start(ctx).await.unwrap();
1254 });
1255
1256 tokio::time::sleep(Duration::from_millis(150)).await;
1257 token.cancel();
1258
1259 let result = tokio::time::timeout(Duration::from_secs(1), handle).await;
1260 assert!(
1261 result.is_ok(),
1262 "Consumer should have stopped after cancellation"
1263 );
1264 }
1265
1266 #[tokio::test]
1271 async fn test_file_producer_writes_file() {
1272 use tower::ServiceExt;
1273
1274 let dir = tempfile::tempdir().unwrap();
1275 let dir_path = dir.path().to_str().unwrap();
1276
1277 let component = FileComponent::new();
1278 let ctx = NoOpComponentContext;
1279 let endpoint = component
1280 .create_endpoint(&format!("file:{dir_path}"), &ctx)
1281 .unwrap();
1282 let ctx = test_producer_ctx();
1283 let producer = endpoint.create_producer(&ctx).unwrap();
1284
1285 let mut exchange = Exchange::new(Message::new("file content"));
1286 exchange.input.set_header(
1287 "CamelFileName",
1288 serde_json::Value::String("output.txt".to_string()),
1289 );
1290
1291 let result = producer.oneshot(exchange).await.unwrap();
1292
1293 let content = std::fs::read_to_string(dir.path().join("output.txt")).unwrap();
1294 assert_eq!(content, "file content");
1295
1296 assert!(result.input.header("CamelFileNameProduced").is_some());
1297 }
1298
1299 #[tokio::test]
1300 async fn test_file_producer_auto_create_dirs() {
1301 use tower::ServiceExt;
1302
1303 let dir = tempfile::tempdir().unwrap();
1304 let dir_path = dir.path().to_str().unwrap();
1305
1306 let component = FileComponent::new();
1307 let ctx = NoOpComponentContext;
1308 let endpoint = component
1309 .create_endpoint(&format!("file:{dir_path}/sub/dir"), &ctx)
1310 .unwrap();
1311 let ctx = test_producer_ctx();
1312 let producer = endpoint.create_producer(&ctx).unwrap();
1313
1314 let mut exchange = Exchange::new(Message::new("nested"));
1315 exchange.input.set_header(
1316 "CamelFileName",
1317 serde_json::Value::String("file.txt".to_string()),
1318 );
1319
1320 producer.oneshot(exchange).await.unwrap();
1321
1322 assert!(dir.path().join("sub/dir/file.txt").exists());
1323 }
1324
1325 #[tokio::test]
1326 async fn test_file_producer_file_exist_fail() {
1327 use tower::ServiceExt;
1328
1329 let dir = tempfile::tempdir().unwrap();
1330 let dir_path = dir.path().to_str().unwrap();
1331
1332 std::fs::write(dir.path().join("existing.txt"), "old").unwrap();
1333
1334 let component = FileComponent::new();
1335 let ctx = NoOpComponentContext;
1336 let endpoint = component
1337 .create_endpoint(&format!("file:{dir_path}?fileExist=Fail"), &ctx)
1338 .unwrap();
1339 let ctx = test_producer_ctx();
1340 let producer = endpoint.create_producer(&ctx).unwrap();
1341
1342 let mut exchange = Exchange::new(Message::new("new"));
1343 exchange.input.set_header(
1344 "CamelFileName",
1345 serde_json::Value::String("existing.txt".to_string()),
1346 );
1347
1348 let result = producer.oneshot(exchange).await;
1349 assert!(
1350 result.is_err(),
1351 "Should fail when file exists with Fail strategy"
1352 );
1353 }
1354
1355 #[tokio::test]
1356 async fn test_file_producer_file_exist_append() {
1357 use tower::ServiceExt;
1358
1359 let dir = tempfile::tempdir().unwrap();
1360 let dir_path = dir.path().to_str().unwrap();
1361
1362 std::fs::write(dir.path().join("append.txt"), "old").unwrap();
1363
1364 let component = FileComponent::new();
1365 let ctx = NoOpComponentContext;
1366 let endpoint = component
1367 .create_endpoint(&format!("file:{dir_path}?fileExist=Append"), &ctx)
1368 .unwrap();
1369 let ctx = test_producer_ctx();
1370 let producer = endpoint.create_producer(&ctx).unwrap();
1371
1372 let mut exchange = Exchange::new(Message::new("new"));
1373 exchange.input.set_header(
1374 "CamelFileName",
1375 serde_json::Value::String("append.txt".to_string()),
1376 );
1377
1378 producer.oneshot(exchange).await.unwrap();
1379
1380 let content = std::fs::read_to_string(dir.path().join("append.txt")).unwrap();
1381 assert_eq!(content, "oldnew");
1382 }
1383
1384 #[tokio::test]
1385 async fn test_file_producer_temp_prefix() {
1386 use tower::ServiceExt;
1387
1388 let dir = tempfile::tempdir().unwrap();
1389 let dir_path = dir.path().to_str().unwrap();
1390
1391 let component = FileComponent::new();
1392 let ctx = NoOpComponentContext;
1393 let endpoint = component
1394 .create_endpoint(&format!("file:{dir_path}?tempPrefix=.tmp"), &ctx)
1395 .unwrap();
1396 let ctx = test_producer_ctx();
1397 let producer = endpoint.create_producer(&ctx).unwrap();
1398
1399 let mut exchange = Exchange::new(Message::new("atomic write"));
1400 exchange.input.set_header(
1401 "CamelFileName",
1402 serde_json::Value::String("final.txt".to_string()),
1403 );
1404
1405 producer.oneshot(exchange).await.unwrap();
1406
1407 assert!(dir.path().join("final.txt").exists());
1408 assert!(!dir.path().join(".tmpfinal.txt").exists());
1409 let content = std::fs::read_to_string(dir.path().join("final.txt")).unwrap();
1410 assert_eq!(content, "atomic write");
1411 }
1412
1413 #[tokio::test]
1414 async fn test_file_producer_uses_filename_option() {
1415 use tower::ServiceExt;
1416
1417 let dir = tempfile::tempdir().unwrap();
1418 let dir_path = dir.path().to_str().unwrap();
1419
1420 let component = FileComponent::new();
1421 let ctx = NoOpComponentContext;
1422 let endpoint = component
1423 .create_endpoint(&format!("file:{dir_path}?fileName=fixed.txt"), &ctx)
1424 .unwrap();
1425 let ctx = test_producer_ctx();
1426 let producer = endpoint.create_producer(&ctx).unwrap();
1427
1428 let exchange = Exchange::new(Message::new("content"));
1429
1430 producer.oneshot(exchange).await.unwrap();
1431 assert!(dir.path().join("fixed.txt").exists());
1432 }
1433
1434 #[tokio::test]
1435 async fn test_file_producer_no_filename_errors() {
1436 use tower::ServiceExt;
1437
1438 let dir = tempfile::tempdir().unwrap();
1439 let dir_path = dir.path().to_str().unwrap();
1440
1441 let component = FileComponent::new();
1442 let ctx = NoOpComponentContext;
1443 let endpoint = component
1444 .create_endpoint(&format!("file:{dir_path}"), &ctx)
1445 .unwrap();
1446 let ctx = test_producer_ctx();
1447 let producer = endpoint.create_producer(&ctx).unwrap();
1448
1449 let exchange = Exchange::new(Message::new("content"));
1450
1451 let result = producer.oneshot(exchange).await;
1452 assert!(result.is_err(), "Should error when no filename is provided");
1453 }
1454
1455 #[tokio::test]
1460 async fn test_file_producer_rejects_path_traversal_parent_directory() {
1461 use tower::ServiceExt;
1462
1463 let dir = tempfile::tempdir().unwrap();
1464 let dir_path = dir.path().to_str().unwrap();
1465
1466 std::fs::create_dir(dir.path().join("subdir")).unwrap();
1468 std::fs::write(dir.path().join("secret.txt"), "secret").unwrap();
1469
1470 let component = FileComponent::new();
1471 let ctx = NoOpComponentContext;
1472 let endpoint = component
1473 .create_endpoint(&format!("file:{dir_path}/subdir"), &ctx)
1474 .unwrap();
1475 let ctx = test_producer_ctx();
1476 let producer = endpoint.create_producer(&ctx).unwrap();
1477
1478 let mut exchange = Exchange::new(Message::new("malicious"));
1479 exchange.input.set_header(
1480 "CamelFileName",
1481 serde_json::Value::String("../secret.txt".to_string()),
1482 );
1483
1484 let result = producer.oneshot(exchange).await;
1485 assert!(result.is_err(), "Should reject path traversal attempt");
1486
1487 let err = result.unwrap_err();
1488 assert!(
1489 err.to_string().contains("outside"),
1490 "Error should mention path is outside base directory"
1491 );
1492 }
1493
1494 #[tokio::test]
1495 async fn test_file_producer_rejects_absolute_path_outside_base() {
1496 use tower::ServiceExt;
1497
1498 let dir = tempfile::tempdir().unwrap();
1499 let dir_path = dir.path().to_str().unwrap();
1500
1501 let component = FileComponent::new();
1502 let ctx = NoOpComponentContext;
1503 let endpoint = component
1504 .create_endpoint(&format!("file:{dir_path}"), &ctx)
1505 .unwrap();
1506 let ctx = test_producer_ctx();
1507 let producer = endpoint.create_producer(&ctx).unwrap();
1508
1509 let mut exchange = Exchange::new(Message::new("malicious"));
1510 exchange.input.set_header(
1511 "CamelFileName",
1512 serde_json::Value::String("/etc/passwd".to_string()),
1513 );
1514
1515 let result = producer.oneshot(exchange).await;
1516 assert!(result.is_err(), "Should reject absolute path outside base");
1517 }
1518
1519 #[tokio::test]
1524 #[ignore] async fn test_large_file_streaming_constant_memory() {
1526 use std::io::Write;
1527 use tempfile::NamedTempFile;
1528
1529 let mut temp_file = NamedTempFile::new().unwrap();
1531 let file_size = 150 * 1024 * 1024; let chunk = vec![b'X'; 1024 * 1024]; for _ in 0..150 {
1535 temp_file.write_all(&chunk).unwrap();
1536 }
1537 temp_file.flush().unwrap();
1538
1539 let dir = temp_file.path().parent().unwrap();
1540 let dir_path = dir.to_str().unwrap();
1541 let file_name = temp_file
1542 .path()
1543 .file_name()
1544 .unwrap()
1545 .to_str()
1546 .unwrap()
1547 .to_string();
1548
1549 let component = FileComponent::new();
1551 let component_ctx = NoOpComponentContext;
1552 let endpoint = component
1553 .create_endpoint(
1554 &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&fileName={file_name}"),
1555 &component_ctx,
1556 )
1557 .unwrap();
1558 let mut consumer = endpoint.create_consumer().unwrap();
1559
1560 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1561 let token = CancellationToken::new();
1562 let ctx = ConsumerContext::new(tx, token.clone());
1563
1564 tokio::spawn(async move {
1565 let _ = consumer.start(ctx).await;
1566 });
1567
1568 let exchange = tokio::time::timeout(Duration::from_secs(5), async {
1569 rx.recv().await.unwrap().exchange
1570 })
1571 .await
1572 .expect("Should receive exchange");
1573 token.cancel();
1574
1575 assert!(matches!(exchange.input.body, Body::Stream(_)));
1577
1578 if let Body::Stream(ref stream_body) = exchange.input.body {
1580 assert!(stream_body.metadata.size_hint.is_some());
1581 let size = stream_body.metadata.size_hint.unwrap();
1582 assert_eq!(size, file_size as u64);
1583 }
1584
1585 if let Body::Stream(stream_body) = exchange.input.body {
1587 let body = Body::Stream(stream_body);
1588 let result = body.into_bytes(100 * 1024 * 1024).await;
1589 assert!(result.is_err());
1590 }
1591
1592 let component2 = FileComponent::new();
1595 let endpoint2 = component2
1596 .create_endpoint(
1597 &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&fileName={file_name}"),
1598 &component_ctx,
1599 )
1600 .unwrap();
1601 let mut consumer2 = endpoint2.create_consumer().unwrap();
1602
1603 let (tx2, mut rx2) = tokio::sync::mpsc::channel(16);
1604 let token2 = CancellationToken::new();
1605 let ctx2 = ConsumerContext::new(tx2, token2.clone());
1606
1607 tokio::spawn(async move {
1608 let _ = consumer2.start(ctx2).await;
1609 });
1610
1611 let exchange2 = tokio::time::timeout(Duration::from_secs(5), async {
1612 rx2.recv().await.unwrap().exchange
1613 })
1614 .await
1615 .expect("Should receive exchange");
1616 token2.cancel();
1617
1618 if let Body::Stream(stream_body) = exchange2.input.body {
1619 let mut stream_lock = stream_body.stream.lock().await;
1620 let mut stream = stream_lock.take().unwrap();
1621
1622 if let Some(chunk_result) = stream.next().await {
1624 let chunk = chunk_result.unwrap();
1625 assert!(!chunk.is_empty());
1626 assert!(chunk.len() < file_size);
1627 }
1629 }
1630 }
1631
1632 #[tokio::test]
1637 async fn test_producer_writes_stream_body() {
1638 let dir = tempfile::tempdir().unwrap();
1639 let dir_path = dir.path().to_str().unwrap();
1640 let uri = format!("file:{dir_path}?fileName=out.txt");
1641
1642 let component = FileComponent::new();
1643 let ctx = NoOpComponentContext;
1644 let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
1645 let producer = endpoint.create_producer(&test_producer_ctx()).unwrap();
1646
1647 let chunks: Vec<Result<Bytes, CamelError>> = vec![
1648 Ok(Bytes::from("hello ")),
1649 Ok(Bytes::from("streaming ")),
1650 Ok(Bytes::from("world")),
1651 ];
1652 let stream = futures::stream::iter(chunks);
1653 let body = Body::Stream(StreamBody {
1654 stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
1655 metadata: StreamMetadata {
1656 size_hint: None,
1657 content_type: None,
1658 origin: None,
1659 },
1660 });
1661
1662 let exchange = Exchange::new(Message::new(body));
1663 tower::ServiceExt::oneshot(producer, exchange)
1664 .await
1665 .unwrap();
1666
1667 let content = tokio::fs::read_to_string(format!("{dir_path}/out.txt"))
1668 .await
1669 .unwrap();
1670 assert_eq!(content, "hello streaming world");
1671 }
1672
1673 #[tokio::test]
1674 async fn test_producer_stream_atomic_no_partial_on_error() {
1675 let dir = tempfile::tempdir().unwrap();
1677 let dir_path = dir.path().to_str().unwrap();
1678 let uri = format!("file:{dir_path}?fileName=out.txt");
1679
1680 let component = FileComponent::new();
1681 let ctx = NoOpComponentContext;
1682 let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
1683 let producer = endpoint.create_producer(&test_producer_ctx()).unwrap();
1684
1685 let chunks: Vec<Result<Bytes, CamelError>> = vec![
1686 Ok(Bytes::from("partial")),
1687 Err(CamelError::ProcessorError(
1688 "simulated stream error".to_string(),
1689 )),
1690 ];
1691 let stream = futures::stream::iter(chunks);
1692 let body = Body::Stream(StreamBody {
1693 stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
1694 metadata: StreamMetadata {
1695 size_hint: None,
1696 content_type: None,
1697 origin: None,
1698 },
1699 });
1700
1701 let exchange = Exchange::new(Message::new(body));
1702 let result = tower::ServiceExt::oneshot(producer, exchange).await;
1703 assert!(
1704 result.is_err(),
1705 "expected error when stream fails mid-write"
1706 );
1707
1708 assert!(
1710 !std::path::Path::new(&format!("{dir_path}/out.txt")).exists(),
1711 "partial file must not exist after failed write"
1712 );
1713
1714 assert!(
1716 !std::path::Path::new(&format!("{dir_path}/.tmp.out.txt")).exists(),
1717 "temp file must be cleaned up after failed write"
1718 );
1719 }
1720
1721 #[tokio::test]
1722 async fn test_producer_stream_append() {
1723 let dir = tempfile::tempdir().unwrap();
1724 let dir_path = dir.path().to_str().unwrap();
1725 let target = format!("{dir_path}/out.txt");
1726
1727 tokio::fs::write(&target, b"line1\n").await.unwrap();
1729
1730 let uri = format!("file:{dir_path}?fileName=out.txt&fileExist=Append");
1731 let component = FileComponent::new();
1732 let ctx = NoOpComponentContext;
1733 let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
1734 let producer = endpoint.create_producer(&test_producer_ctx()).unwrap();
1735
1736 let chunks: Vec<Result<Bytes, CamelError>> = vec![Ok(Bytes::from("line2\n"))];
1737 let stream = futures::stream::iter(chunks);
1738 let body = Body::Stream(StreamBody {
1739 stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
1740 metadata: StreamMetadata {
1741 size_hint: None,
1742 content_type: None,
1743 origin: None,
1744 },
1745 });
1746
1747 let exchange = Exchange::new(Message::new(body));
1748 tower::ServiceExt::oneshot(producer, exchange)
1749 .await
1750 .unwrap();
1751
1752 let content = tokio::fs::read_to_string(&target).await.unwrap();
1753 assert_eq!(content, "line1\nline2\n");
1754 }
1755
1756 #[tokio::test]
1757 async fn test_producer_stream_append_partial_on_error() {
1758 let dir = tempfile::tempdir().unwrap();
1761 let dir_path = dir.path().to_str().unwrap();
1762 let target = format!("{dir_path}/out.txt");
1763
1764 tokio::fs::write(&target, b"initial\n").await.unwrap();
1766
1767 let uri = format!("file:{dir_path}?fileName=out.txt&fileExist=Append");
1768 let component = FileComponent::new();
1769 let ctx = NoOpComponentContext;
1770 let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
1771 let producer = endpoint.create_producer(&test_producer_ctx()).unwrap();
1772
1773 let chunks: Vec<Result<Bytes, CamelError>> = vec![
1775 Ok(Bytes::from("partial-")), Err(CamelError::ProcessorError("stream error".to_string())), Ok(Bytes::from("never-written")), ];
1779 let stream = futures::stream::iter(chunks);
1780 let body = Body::Stream(StreamBody {
1781 stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
1782 metadata: StreamMetadata {
1783 size_hint: None,
1784 content_type: None,
1785 origin: None,
1786 },
1787 });
1788
1789 let exchange = Exchange::new(Message::new(body));
1790 let result = tower::ServiceExt::oneshot(producer, exchange).await;
1791
1792 assert!(
1794 result.is_err(),
1795 "expected error when stream fails during append"
1796 );
1797
1798 let content = tokio::fs::read_to_string(&target).await.unwrap();
1800 assert_eq!(
1801 content, "initial\npartial-",
1802 "append leaves partial data on stream error (non-atomic by nature)"
1803 );
1804 }
1805
1806 #[tokio::test]
1807 async fn test_producer_stream_already_consumed_errors() {
1808 let dir = tempfile::tempdir().unwrap();
1809 let dir_path = dir.path().to_str().unwrap();
1810 let uri = format!("file:{dir_path}?fileName=out.txt");
1811
1812 let component = FileComponent::new();
1813 let ctx = NoOpComponentContext;
1814 let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
1815 let producer = endpoint.create_producer(&test_producer_ctx()).unwrap();
1816
1817 type MaybeStream = std::sync::Arc<
1819 tokio::sync::Mutex<
1820 Option<
1821 std::pin::Pin<
1822 Box<dyn futures::Stream<Item = Result<Bytes, CamelError>> + Send>,
1823 >,
1824 >,
1825 >,
1826 >;
1827 let arc: MaybeStream = std::sync::Arc::new(tokio::sync::Mutex::new(None));
1828 let body = Body::Stream(StreamBody {
1829 stream: arc,
1830 metadata: StreamMetadata {
1831 size_hint: None,
1832 content_type: None,
1833 origin: None,
1834 },
1835 });
1836
1837 let exchange = Exchange::new(Message::new(body));
1838 let result = tower::ServiceExt::oneshot(producer, exchange).await;
1839 assert!(
1840 result.is_err(),
1841 "expected error for already-consumed stream"
1842 );
1843 }
1844
1845 #[test]
1850 fn test_global_config_applied_to_endpoint() {
1851 let global = FileGlobalConfig::default()
1853 .with_delay_ms(2000)
1854 .with_initial_delay_ms(5000)
1855 .with_read_timeout_ms(60_000)
1856 .with_write_timeout_ms(45_000);
1857 let component = FileComponent::with_config(global);
1858 let ctx = NoOpComponentContext;
1859 let endpoint = component.create_endpoint("file:/tmp/inbox", &ctx).unwrap();
1861 let mut config = FileConfig::from_uri("file:/tmp/inbox").unwrap();
1864 let global2 = FileGlobalConfig::default()
1865 .with_delay_ms(2000)
1866 .with_initial_delay_ms(5000)
1867 .with_read_timeout_ms(60_000)
1868 .with_write_timeout_ms(45_000);
1869 config.apply_global_defaults(&global2);
1870 assert_eq!(config.delay, Duration::from_millis(2000));
1871 assert_eq!(config.initial_delay, Duration::from_millis(5000));
1872 assert_eq!(config.read_timeout, Duration::from_millis(60_000));
1873 assert_eq!(config.write_timeout, Duration::from_millis(45_000));
1874 let _ = endpoint; }
1877
1878 #[test]
1879 fn test_uri_param_wins_over_global_config() {
1880 let mut config =
1882 FileConfig::from_uri("file:/tmp/inbox?delay=1000&initialDelay=2000").unwrap();
1883 let global = FileGlobalConfig::default()
1885 .with_delay_ms(3000)
1886 .with_initial_delay_ms(4000);
1887 config.apply_global_defaults(&global);
1888 assert_eq!(config.delay, Duration::from_millis(1000));
1890 assert_eq!(config.initial_delay, Duration::from_millis(2000));
1892 assert_eq!(config.read_timeout, Duration::from_millis(30_000));
1895 }
1896
1897 #[tokio::test]
1898 async fn test_file_producer_filename_simple_language_from_header() {
1899 use tower::ServiceExt;
1900
1901 let dir = tempfile::tempdir().unwrap();
1902 let dir_path = dir.path().to_str().unwrap();
1903
1904 let component = FileComponent::new();
1905 let ctx = NoOpComponentContext;
1906 let endpoint = component
1907 .create_endpoint(&format!("file:{dir_path}"), &ctx)
1908 .unwrap();
1909 let ctx = test_producer_ctx();
1910 let producer = endpoint.create_producer(&ctx).unwrap();
1911
1912 let mut exchange = Exchange::new(Message::new("content"));
1913 exchange
1914 .input
1915 .set_header("CamelTimerCounter", serde_json::Value::Number(42.into()));
1916 exchange.input.set_header(
1917 "CamelFileName",
1918 serde_json::Value::String("test-${header.CamelTimerCounter}.txt".to_string()),
1919 );
1920
1921 producer.oneshot(exchange).await.unwrap();
1922
1923 assert!(
1924 dir.path().join("test-42.txt").exists(),
1925 "fileName should have been evaluated from Simple Language expression"
1926 );
1927 let content = std::fs::read_to_string(dir.path().join("test-42.txt")).unwrap();
1928 assert_eq!(content, "content");
1929 }
1930
1931 #[tokio::test]
1932 async fn test_file_producer_filename_simple_language_from_uri_param() {
1933 use tower::ServiceExt;
1934
1935 let dir = tempfile::tempdir().unwrap();
1936 let dir_path = dir.path().to_str().unwrap();
1937
1938 let component = FileComponent::new();
1939 let ctx = NoOpComponentContext;
1940 let endpoint = component
1941 .create_endpoint(
1942 &format!("file:{dir_path}?fileName=msg-${{header.id}}.dat"),
1943 &ctx,
1944 )
1945 .unwrap();
1946 let ctx = test_producer_ctx();
1947 let producer = endpoint.create_producer(&ctx).unwrap();
1948
1949 let mut exchange = Exchange::new(Message::new("data"));
1950 exchange
1951 .input
1952 .set_header("id", serde_json::Value::String("abc".to_string()));
1953
1954 producer.oneshot(exchange).await.unwrap();
1955
1956 assert!(
1957 dir.path().join("msg-abc.dat").exists(),
1958 "fileName URI param should have been evaluated from Simple Language expression"
1959 );
1960 }
1961
1962 #[tokio::test]
1963 async fn test_file_producer_filename_literal_without_expression() {
1964 use tower::ServiceExt;
1965
1966 let dir = tempfile::tempdir().unwrap();
1967 let dir_path = dir.path().to_str().unwrap();
1968
1969 let component = FileComponent::new();
1970 let ctx = NoOpComponentContext;
1971 let endpoint = component
1972 .create_endpoint(&format!("file:{dir_path}?fileName=plain.txt"), &ctx)
1973 .unwrap();
1974 let ctx = test_producer_ctx();
1975 let producer = endpoint.create_producer(&ctx).unwrap();
1976
1977 let exchange = Exchange::new(Message::new("data"));
1978 producer.oneshot(exchange).await.unwrap();
1979
1980 assert!(
1981 dir.path().join("plain.txt").exists(),
1982 "literal fileName without expressions should still work"
1983 );
1984 }
1985}