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