Skip to main content

camel_component_file/
lib.rs

1//! File system component for rust-camel — polls directories for new or changed
2//! files as consumer, writes exchange bodies to files as producer.
3//!
4//! Main types: `FileBundle`, `FileComponent`, `FileConsumer`, `FileProducer`.
5//! Main modules: `bundle`.
6
7pub 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
39// ---------------------------------------------------------------------------
40// TempFileGuard — RAII cleanup for temp files (panic-safe)
41// ---------------------------------------------------------------------------
42
43/// RAII guard that ensures temp file cleanup even on panic.
44///
45/// When dropped, removes the file at `path` unless `disarm` is set to true.
46/// This protects against temp file leaks if `io::copy` panics mid-write.
47struct 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    /// Call after successful rename to prevent cleanup.
61    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            // Best-effort cleanup; ignore errors (file may not exist)
70            let _ = std::fs::remove_file(&self.path);
71        }
72    }
73}
74
75// ---------------------------------------------------------------------------
76// FileExistStrategy
77// ---------------------------------------------------------------------------
78
79/// Strategy for handling existing files when writing.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
81pub enum FileExistStrategy {
82    /// Overwrite existing file (default).
83    #[default]
84    Override,
85    /// Append to existing file.
86    Append,
87    /// Fail if file exists.
88    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), // Default for unknown values
100        }
101    }
102}
103
104// ---------------------------------------------------------------------------
105// FileGlobalConfig
106// ---------------------------------------------------------------------------
107
108/// Global configuration for File component.
109/// Supports serde deserialization with defaults and builder methods.
110/// These are the fallback defaults when URI params are not set.
111#[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// ---------------------------------------------------------------------------
154// FileConfig
155// ---------------------------------------------------------------------------
156
157/// Configuration for file component endpoints.
158///
159/// # Streaming
160///
161/// Both the file consumer and producer use **native streaming** with no RAM
162/// materialization:
163///
164/// - The **consumer** creates a `Body::Stream` backed by `tokio::fs::File` via
165///   `ReaderStream`. Files of any size are handled without loading them into memory.
166///
167/// - The **producer** writes via `tokio::io::copy` directly to a `tokio::fs::File`
168///   using `Body::into_async_read()`. Writes for the `Override` strategy are
169///   **atomic**: data is written to a temporary file first and renamed only on
170///   success, preventing partial files on failure.
171///
172/// # Write strategies (`fileExist` URI parameter)
173///
174/// | Value | Behavior |
175/// |-------|----------|
176/// | `Override` (default) | Atomic write via temp file + rename |
177/// | `Append` | Appends to existing file; non-atomic by nature |
178/// | `Fail` | Returns error if file already exists |
179#[derive(Debug, Clone, UriConfig)]
180#[uri_scheme = "file"]
181#[uri_config(skip_impl, crate = "camel_component_api")]
182pub struct FileConfig {
183    /// Directory path to read from or write to.
184    pub directory: String,
185
186    /// Polling delay in milliseconds (companion field for `delay`).
187    #[allow(dead_code)]
188    #[uri_param(name = "delay", default = "500")]
189    delay_ms: u64,
190
191    /// Polling delay as Duration.
192    pub delay: Duration,
193
194    /// Initial delay in milliseconds (companion field for `initial_delay`).
195    #[allow(dead_code)]
196    #[uri_param(name = "initialDelay", default = "1000")]
197    initial_delay_ms: u64,
198
199    /// Initial delay as Duration.
200    pub initial_delay: Duration,
201
202    /// If true, don't delete or move files after processing.
203    #[uri_param(default = "false")]
204    pub noop: bool,
205
206    /// If true, delete files after processing.
207    #[uri_param(default = "false")]
208    pub delete: bool,
209
210    /// Directory to move processed files to (only if not noop/delete).
211    /// Default is ".camel" when not specified and noop/delete are false.
212    #[uri_param(name = "move")]
213    move_to: Option<String>,
214
215    /// Fixed filename for producer (optional).
216    #[uri_param(name = "fileName")]
217    pub file_name: Option<String>,
218
219    /// Regex pattern for including files (consumer).
220    #[uri_param]
221    pub include: Option<String>,
222
223    /// Regex pattern for excluding files (consumer).
224    #[uri_param]
225    pub exclude: Option<String>,
226
227    /// Whether to scan directories recursively.
228    #[uri_param(default = "false")]
229    pub recursive: bool,
230
231    /// Strategy for handling existing files when writing.
232    #[uri_param(name = "fileExist", default = "Override")]
233    pub file_exist: FileExistStrategy,
234
235    /// Prefix for temporary files during atomic writes.
236    #[uri_param(name = "tempPrefix")]
237    pub temp_prefix: Option<String>,
238
239    /// Whether to automatically create directories.
240    #[uri_param(name = "autoCreate", default = "true")]
241    pub auto_create: bool,
242
243    /// Read timeout in milliseconds (companion field for `read_timeout`).
244    #[allow(dead_code)]
245    #[uri_param(name = "readTimeout", default = "30000")]
246    read_timeout_ms: u64,
247
248    /// Read timeout as Duration.
249    pub read_timeout: Duration,
250
251    /// Write timeout in milliseconds (companion field for `write_timeout`).
252    #[allow(dead_code)]
253    #[uri_param(name = "writeTimeout", default = "30000")]
254    write_timeout_ms: u64,
255
256    /// Write timeout as Duration.
257    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        // Apply conditional logic for move_to:
276        // - If noop or delete is true, move_to should be None
277        // - Otherwise, if move_to is None, default to ".camel"
278        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    /// Apply global config defaults. Since FileConfig uses a proc macro that bakes in
290    /// defaults, we compare Duration values against the known macro defaults to detect
291    /// "not explicitly set by user". Only overrides when current value == macro default.
292    ///
293    /// **Note**: If a user explicitly sets a URI param to its default value (e.g.,
294    /// `?delay=500`), it is indistinguishable from "not set" and will be overridden
295    /// by global config. This is a known limitation of the Duration comparison approach.
296    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
312// ---------------------------------------------------------------------------
313// FileComponent
314// ---------------------------------------------------------------------------
315
316pub 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
363// ---------------------------------------------------------------------------
364// FileEndpoint
365// ---------------------------------------------------------------------------
366
367struct 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
391// ---------------------------------------------------------------------------
392// FileConsumer
393// ---------------------------------------------------------------------------
394
395struct 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        // Idempotent consumer: skip already-seen files when noop=true
501        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            // Do nothing
599        } 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
645// ---------------------------------------------------------------------------
646// Path validation for security
647// ---------------------------------------------------------------------------
648
649fn 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    // For non-existent paths, canonicalize the parent and construct the full path
658    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        // Ensure parent exists (should have been created by auto_create)
664        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        // Reconstruct the full path with the filename
674        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// ---------------------------------------------------------------------------
699// FileProducer
700// ---------------------------------------------------------------------------
701
702#[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            // 1. Auto-create directories
767            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            // 2. Security: validate path is within base directory
777            validate_path_is_within_base(dir_path, &target_path)?;
778
779            // 3. Handle file-exist strategy
780            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                    // Append: write directly without temp file (append is inherently non-atomic)
789                    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                    // Override (or Fail when file doesn't exist): always atomic via temp file
814                    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                    // RAII guard ensures cleanup even on panic
822                    let mut guard = TempFileGuard::new(temp_path.clone());
823
824                    // Write to temp file
825                    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                    // Flush any kernel buffers (best-effort; actual write errors come from io::copy above)
840                    let _ = file.flush().await;
841
842                    match copy_result {
843                        Ok(Ok(_)) => {}
844                        Ok(Err(e)) => {
845                            // Guard will clean up temp file on drop
846                            return Err(CamelError::ProcessorError(e.to_string()));
847                        }
848                        Err(_) => {
849                            // Guard will clean up temp file on drop
850                            return Err(CamelError::ProcessorError("Timeout writing file".into()));
851                        }
852                    }
853
854                    // Atomic rename: temp → target
855                    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                            // Success — disarm guard so it doesn't delete the renamed file
864                            guard.disarm();
865                        }
866                        Ok(Err(e)) => {
867                            // Guard will clean up temp file on drop
868                            return Err(CamelError::from(e));
869                        }
870                        Err(_) => {
871                            // Guard will clean up temp file on drop
872                            return Err(CamelError::ProcessorError("Timeout renaming file".into()));
873                        }
874                    }
875                }
876            }
877
878            // 4. Set output header
879            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        // New timeout defaults
928        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    // -----------------------------------------------------------------------
992    // Consumer tests
993    // -----------------------------------------------------------------------
994
995    #[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    // -----------------------------------------------------------------------
1267    // Producer tests
1268    // -----------------------------------------------------------------------
1269
1270    #[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    // -----------------------------------------------------------------------
1456    // Security tests - Path traversal protection
1457    // -----------------------------------------------------------------------
1458
1459    #[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        // Create a subdirectory
1467        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    // -----------------------------------------------------------------------
1520    // Large file streaming tests
1521    // -----------------------------------------------------------------------
1522
1523    #[tokio::test]
1524    #[ignore] // Slow test - run with --ignored flag
1525    async fn test_large_file_streaming_constant_memory() {
1526        use std::io::Write;
1527        use tempfile::NamedTempFile;
1528
1529        // Create a 150MB file (larger than 100MB limit)
1530        let mut temp_file = NamedTempFile::new().unwrap();
1531        let file_size = 150 * 1024 * 1024; // 150MB
1532        let chunk = vec![b'X'; 1024 * 1024]; // 1MB chunk
1533
1534        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        // Read file as stream (should succeed with lazy evaluation)
1550        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        // Verify body is a stream (not materialized)
1576        assert!(matches!(exchange.input.body, Body::Stream(_)));
1577
1578        // Verify we can read metadata without consuming
1579        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        // Materializing should fail (exceeds 100MB limit)
1586        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        // But we CAN read chunks one at a time (simulating line-by-line processing)
1593        // This demonstrates lazy evaluation - we don't need to load entire file
1594        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            // Read first chunk (size varies based on ReaderStream's buffer)
1623            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                // Memory usage is constant - we only have this chunk in memory, not 150MB
1628            }
1629        }
1630    }
1631
1632    // -----------------------------------------------------------------------
1633    // Streaming producer tests
1634    // -----------------------------------------------------------------------
1635
1636    #[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        // If the stream errors mid-write, no file should exist at the target path
1676        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        // Target file must NOT exist — write was aborted and temp file cleaned up
1709        assert!(
1710            !std::path::Path::new(&format!("{dir_path}/out.txt")).exists(),
1711            "partial file must not exist after failed write"
1712        );
1713
1714        // Temp file must also be cleaned up
1715        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        // Pre-create file with initial content
1728        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        // Append is inherently non-atomic: if the stream errors mid-write,
1759        // the file will contain partial data. This test documents that behavior.
1760        let dir = tempfile::tempdir().unwrap();
1761        let dir_path = dir.path().to_str().unwrap();
1762        let target = format!("{dir_path}/out.txt");
1763
1764        // Pre-create file with initial content
1765        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        // Stream with an error in the middle
1774        let chunks: Vec<Result<Bytes, CamelError>> = vec![
1775            Ok(Bytes::from("partial-")), // This will be written
1776            Err(CamelError::ProcessorError("stream error".to_string())), // This causes failure
1777            Ok(Bytes::from("never-written")), // This won't be reached
1778        ];
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        // 1. Producer must return an error
1793        assert!(
1794            result.is_err(),
1795            "expected error when stream fails during append"
1796        );
1797
1798        // 2. File must contain initial content + partial data written before the error
1799        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        // Mutex holds None -> stream already consumed
1818        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    // -----------------------------------------------------------------------
1846    // GlobalConfig tests - apply_global_defaults behavior
1847    // -----------------------------------------------------------------------
1848
1849    #[test]
1850    fn test_global_config_applied_to_endpoint() {
1851        // Global config with non-default values
1852        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        // URI uses no explicit delay/timeout params → macro defaults apply
1860        let endpoint = component.create_endpoint("file:/tmp/inbox", &ctx).unwrap();
1861        // We cannot call endpoint.config directly (FileEndpoint is private),
1862        // but we can test apply_global_defaults on FileConfig directly:
1863        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        // endpoint creation succeeds too
1875        let _ = endpoint; // just verify create_endpoint didn't fail
1876    }
1877
1878    #[test]
1879    fn test_uri_param_wins_over_global_config() {
1880        // URI explicitly sets delay=1000 (NOT the 500ms macro default)
1881        let mut config =
1882            FileConfig::from_uri("file:/tmp/inbox?delay=1000&initialDelay=2000").unwrap();
1883        // Global config would want 3000ms delay
1884        let global = FileGlobalConfig::default()
1885            .with_delay_ms(3000)
1886            .with_initial_delay_ms(4000);
1887        config.apply_global_defaults(&global);
1888        // URI value of 1000ms must be preserved (not replaced by 3000ms)
1889        assert_eq!(config.delay, Duration::from_millis(1000));
1890        // URI value of 2000ms must be preserved (not replaced by 4000ms)
1891        assert_eq!(config.initial_delay, Duration::from_millis(2000));
1892        // read_timeout was not set by URI → macro default (30000) → global wins if different
1893        // (read_timeout stays at 30000 since global has same default = 30000)
1894        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}