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
7mod atomic_write;
8pub mod bundle;
9pub mod health;
10mod poll_logic;
11mod polling_consumer;
12
13use poll_logic::poll_directory;
14
15pub use bundle::FileBundle;
16pub use health::FileHealthCheck;
17
18use std::collections::HashSet;
19use std::future::Future;
20use std::path::PathBuf;
21use std::pin::Pin;
22use std::str::FromStr;
23use std::sync::Arc;
24use std::task::{Context, Poll};
25use std::time::Duration;
26
27use async_trait::async_trait;
28use dashmap::DashMap;
29use regex::Regex;
30use tokio::fs;
31use tokio::fs::OpenOptions;
32use tokio::io;
33use tokio::io::AsyncWriteExt;
34use tokio::time;
35use tower::Service;
36use tracing::{debug, warn};
37
38use camel_component_api::{Body, BoxProcessor, CamelError, Exchange};
39use camel_component_api::{
40    Component, Consumer, ConsumerContext, Endpoint, PollingConsumer, ProducerContext,
41};
42use camel_component_api::{UriConfig, parse_uri};
43use camel_language_api::Language;
44use camel_language_simple::SimpleLanguage;
45
46// ---------------------------------------------------------------------------
47// TempFileGuard — RAII cleanup for temp files (panic-safe)
48// ---------------------------------------------------------------------------
49
50/// RAII guard that ensures temp file cleanup even on panic.
51///
52/// When dropped, removes the file at `path` unless `disarm` is set to true.
53/// This protects against temp file leaks if `io::copy` panics mid-write.
54pub(crate) struct TempFileGuard {
55    path: PathBuf,
56    disarm: bool,
57}
58
59impl TempFileGuard {
60    pub(crate) fn new(path: PathBuf) -> Self {
61        Self {
62            path,
63            disarm: false,
64        }
65    }
66
67    /// Call after successful rename to prevent cleanup.
68    pub(crate) fn disarm(&mut self) {
69        self.disarm = true;
70    }
71}
72
73impl Drop for TempFileGuard {
74    fn drop(&mut self) {
75        if !self.disarm {
76            // Best-effort cleanup; ignore errors (file may not exist)
77            let _ = std::fs::remove_file(&self.path);
78        }
79    }
80}
81
82// ---------------------------------------------------------------------------
83// FileExistStrategy
84// ---------------------------------------------------------------------------
85
86/// Strategy for handling existing files when writing.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
88pub enum FileExistStrategy {
89    /// Overwrite existing file (default).
90    #[default]
91    Override,
92    /// Append to existing file.
93    Append,
94    /// Fail if file exists.
95    Fail,
96    /// Skip write if file already exists.
97    Ignore,
98    TryRename,
99}
100
101impl FromStr for FileExistStrategy {
102    type Err = String;
103
104    fn from_str(s: &str) -> Result<Self, Self::Err> {
105        match s {
106            "Override" | "override" => Ok(FileExistStrategy::Override),
107            "Append" | "append" => Ok(FileExistStrategy::Append),
108            "Fail" | "fail" => Ok(FileExistStrategy::Fail),
109            "Ignore" | "ignore" => Ok(FileExistStrategy::Ignore),
110            "TryRename" | "tryRename" => Ok(FileExistStrategy::TryRename),
111            other => Err(format!("unknown FileExistStrategy: {other}")),
112        }
113    }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
117pub enum ReadLockStrategy {
118    #[default]
119    None,
120    InProcess,
121    Rename,
122}
123
124impl FromStr for ReadLockStrategy {
125    type Err = String;
126
127    fn from_str(s: &str) -> Result<Self, Self::Err> {
128        match s {
129            "None" | "none" => Ok(Self::None),
130            "InProcess" | "inProcess" | "inprocess" => Ok(Self::InProcess),
131            "Rename" | "rename" => Ok(Self::Rename),
132            other => Err(format!("unknown ReadLockStrategy: {other}")),
133        }
134    }
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
138pub enum IdempotentKey {
139    #[default]
140    None,
141    FileName,
142    FilePath,
143    FileSize,
144    /// Lightweight fingerprint using file path + last-modified timestamp (not a cryptographic hash).
145    Digest,
146}
147
148impl FromStr for IdempotentKey {
149    type Err = String;
150
151    fn from_str(s: &str) -> Result<Self, Self::Err> {
152        match s {
153            "None" | "none" => Ok(Self::None),
154            "FileName" | "fileName" | "filename" => Ok(Self::FileName),
155            "FilePath" | "filePath" | "filepath" => Ok(Self::FilePath),
156            "FileSize" | "fileSize" | "filesize" => Ok(Self::FileSize),
157            "Digest" | "digest" => Ok(Self::Digest),
158            other => Err(format!("unknown IdempotentKey: {other}")),
159        }
160    }
161}
162
163// ---------------------------------------------------------------------------
164// FileGlobalConfig
165// ---------------------------------------------------------------------------
166
167/// Global configuration for File component.
168/// Supports serde deserialization with defaults and builder methods.
169/// These are the fallback defaults when URI params are not set.
170#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
171#[serde(default)]
172pub struct FileGlobalConfig {
173    pub delay_ms: u64,
174    pub initial_delay_ms: u64,
175    pub read_timeout_ms: u64,
176    pub write_timeout_ms: u64,
177}
178
179impl Default for FileGlobalConfig {
180    fn default() -> Self {
181        Self {
182            delay_ms: 500,
183            initial_delay_ms: 1_000,
184            read_timeout_ms: 30_000,
185            write_timeout_ms: 30_000,
186        }
187    }
188}
189
190impl FileGlobalConfig {
191    pub fn new() -> Self {
192        Self::default()
193    }
194    pub fn with_delay_ms(mut self, v: u64) -> Self {
195        self.delay_ms = v;
196        self
197    }
198    pub fn with_initial_delay_ms(mut self, v: u64) -> Self {
199        self.initial_delay_ms = v;
200        self
201    }
202    pub fn with_read_timeout_ms(mut self, v: u64) -> Self {
203        self.read_timeout_ms = v;
204        self
205    }
206    pub fn with_write_timeout_ms(mut self, v: u64) -> Self {
207        self.write_timeout_ms = v;
208        self
209    }
210}
211
212// ---------------------------------------------------------------------------
213// CompiledFilters — precompiled file selection predicates
214// ---------------------------------------------------------------------------
215
216#[derive(Debug, Clone, Default)]
217pub(crate) struct CompiledFilters {
218    pub include_re: Option<Regex>,
219    pub exclude_re: Option<Regex>,
220    pub ant_include_patterns: Option<Vec<glob::Pattern>>,
221    pub ant_exclude_patterns: Option<Vec<glob::Pattern>>,
222    pub include_exts: Option<Vec<String>>,
223    pub exclude_exts: Option<Vec<String>>,
224}
225
226impl CompiledFilters {
227    pub fn compile(config: &FileConfig) -> Result<Self, CamelError> {
228        let include_re = config
229            .include
230            .as_deref()
231            .map(|p| {
232                Regex::new(p).map_err(|e| CamelError::Config(format!("invalid include regex: {e}")))
233            })
234            .transpose()?;
235        let exclude_re = config
236            .exclude
237            .as_deref()
238            .map(|p| {
239                Regex::new(p).map_err(|e| CamelError::Config(format!("invalid exclude regex: {e}")))
240            })
241            .transpose()?;
242        let ant_include_patterns = config
243            .ant_include
244            .as_deref()
245            .map(|list| {
246                list.split(',')
247                    .map(|s| {
248                        s.trim().parse::<glob::Pattern>().map_err(|e| {
249                            CamelError::Config(format!("invalid antInclude pattern '{s}': {e}"))
250                        })
251                    })
252                    .collect::<Result<Vec<_>, _>>()
253            })
254            .transpose()?;
255        let ant_exclude_patterns = config
256            .ant_exclude
257            .as_deref()
258            .map(|list| {
259                list.split(',')
260                    .map(|s| {
261                        s.trim().parse::<glob::Pattern>().map_err(|e| {
262                            CamelError::Config(format!("invalid antExclude pattern '{s}': {e}"))
263                        })
264                    })
265                    .collect::<Result<Vec<_>, _>>()
266            })
267            .transpose()?;
268        let include_exts = config
269            .include_ext
270            .as_deref()
271            .map(|list| list.split(',').map(|s| s.trim().to_lowercase()).collect());
272        let exclude_exts = config
273            .exclude_ext
274            .as_deref()
275            .map(|list| list.split(',').map(|s| s.trim().to_lowercase()).collect());
276        Ok(Self {
277            include_re,
278            exclude_re,
279            ant_include_patterns,
280            ant_exclude_patterns,
281            include_exts,
282            exclude_exts,
283        })
284    }
285}
286
287// ---------------------------------------------------------------------------
288// SortSpec — sort order for files (file:name, file:length, file:modified)
289// ---------------------------------------------------------------------------
290
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub(crate) enum SortField {
293    Name,
294    Length,
295    Modified,
296}
297
298#[derive(Debug, Clone)]
299pub(crate) struct SortGroup {
300    pub field: SortField,
301    pub reverse: bool,
302    pub ignore_case: bool,
303}
304
305#[derive(Debug, Clone)]
306pub(crate) struct SortSpec {
307    pub groups: Vec<SortGroup>,
308}
309
310impl FromStr for SortSpec {
311    type Err = String;
312
313    fn from_str(s: &str) -> Result<Self, Self::Err> {
314        let mut groups = Vec::new();
315        for raw_group in s.split(';') {
316            let trimmed = raw_group.trim();
317            if trimmed.is_empty() {
318                continue;
319            }
320            let mut reverse = false;
321            let mut ignore_case = false;
322            let mut remaining = trimmed;
323            let mut saw_ignore_case = false;
324
325            loop {
326                if let Some(rest) = remaining.strip_prefix("reverse:") {
327                    if saw_ignore_case {
328                        return Err("sortBy: reverse must precede ignoreCase".into());
329                    }
330                    reverse = true;
331                    remaining = rest;
332                    continue;
333                }
334                if let Some(rest) = remaining.strip_prefix("ignoreCase:") {
335                    ignore_case = true;
336                    saw_ignore_case = true;
337                    remaining = rest;
338                    continue;
339                }
340                break;
341            }
342
343            let field = match remaining {
344                "file:name" => SortField::Name,
345                "file:length" => SortField::Length,
346                "file:modified" => SortField::Modified,
347                other => return Err(format!("unsupported sortBy field: '{other}'")),
348            };
349
350            groups.push(SortGroup {
351                field,
352                reverse,
353                ignore_case,
354            });
355        }
356        if groups.is_empty() {
357            return Err("sortBy must have at least one group".into());
358        }
359        Ok(SortSpec { groups })
360    }
361}
362
363// ---------------------------------------------------------------------------
364// FileConfig
365// ---------------------------------------------------------------------------
366
367/// Configuration for file component endpoints.
368///
369/// # Streaming
370///
371/// Both the file consumer and producer use **native streaming** with no RAM
372/// materialization:
373///
374/// - The **consumer** creates a `Body::Stream` backed by `tokio::fs::File` via
375///   `ReaderStream`. Files of any size are handled without loading them into memory.
376///
377/// - The **producer** writes via `tokio::io::copy` directly to a `tokio::fs::File`
378///   using `Body::into_async_read()`. Writes for the `Override` strategy are
379///   **atomic**: data is written to a temporary file first and renamed only on
380///   success, preventing partial files on failure.
381///
382/// # Write strategies (`fileExist` URI parameter)
383///
384/// | Value | Behavior |
385/// |-------|----------|
386/// | `Override` (default) | Atomic write via temp file + rename |
387/// | `Append` | Appends to existing file; non-atomic by nature |
388/// | `Fail` | Returns error if file already exists |
389/// | `Ignore` | Skip write if file already exists |
390#[derive(Debug, Clone)]
391pub struct FileConfig {
392    /// Directory path to read from or write to.
393    pub directory: String,
394
395    /// Polling delay in milliseconds (companion field for `delay`).
396    #[allow(dead_code)]
397    delay_ms: u64,
398
399    /// Polling delay as Duration.
400    pub delay: Duration,
401
402    /// Initial delay in milliseconds (companion field for `initial_delay`).
403    #[allow(dead_code)]
404    initial_delay_ms: u64,
405
406    /// Initial delay as Duration.
407    pub initial_delay: Duration,
408
409    /// If true, don't delete or move files after processing.
410    pub noop: bool,
411
412    /// If true, delete files after processing.
413    pub delete: bool,
414
415    /// Directory to move processed files to (only if not noop/delete).
416    /// Default is ".camel" when not specified and noop/delete are false.
417    move_to: Option<String>,
418
419    /// Fixed filename for producer (optional).
420    pub file_name: Option<String>,
421
422    /// Regex pattern for including files (consumer).
423    pub include: Option<String>,
424
425    /// Regex pattern for excluding files (consumer).
426    pub exclude: Option<String>,
427
428    /// Ant-style include pattern (comma-separated).
429    pub ant_include: Option<String>,
430
431    /// Ant-style exclude pattern (comma-separated).
432    pub ant_exclude: Option<String>,
433
434    /// File extensions to include (comma-separated).
435    pub include_ext: Option<String>,
436
437    /// File extensions to exclude (comma-separated).
438    pub exclude_ext: Option<String>,
439
440    /// Whether to scan directories recursively.
441    pub recursive: bool,
442
443    /// Strategy for handling existing files when writing.
444    pub file_exist: FileExistStrategy,
445
446    /// Read lock strategy for concurrent consumers.
447    pub read_lock_strategy: ReadLockStrategy,
448
449    /// In-memory idempotent key selector for consumer.
450    pub idempotent_key: IdempotentKey,
451
452    /// Done marker filename pattern created after successful write.
453    pub done_file_name: Option<String>,
454
455    /// Charset for string body encoding (UTF-8, ISO-8859-1).
456    pub charset: Option<String>,
457
458    /// Prefix for temporary files during atomic writes.
459    pub temp_prefix: Option<String>,
460
461    /// If true, fsync temp file and parent directory after atomic write, in the
462    /// correct order (temp → rename → parent). Crash-safe but slower. Opt-in via
463    /// `?durable=true`. Default false (preserves current latency characteristics).
464    pub durable: bool,
465
466    /// Whether to automatically create directories.
467    pub auto_create: bool,
468
469    /// If true, verify the starting directory exists at startup.
470    pub starting_directory_must_exist: bool,
471
472    /// Read timeout in milliseconds (companion field for `read_timeout`).
473    #[allow(dead_code)]
474    read_timeout_ms: u64,
475
476    /// Read timeout as Duration.
477    pub read_timeout: Duration,
478
479    /// Write timeout in milliseconds (companion field for `write_timeout`).
480    #[allow(dead_code)]
481    write_timeout_ms: u64,
482
483    /// Write timeout as Duration.
484    pub write_timeout: Duration,
485
486    pub max_depth: usize,
487    pub min_depth: usize,
488    pub max_messages_per_poll: i64,
489    pub eager_max_messages_per_poll: bool,
490    pub shuffle: bool,
491    pub(crate) sort_spec: Option<SortSpec>,
492}
493
494impl UriConfig for FileConfig {
495    fn scheme() -> &'static str {
496        "file"
497    }
498
499    fn from_uri(uri: &str) -> Result<Self, CamelError> {
500        let parts = parse_uri(uri)?;
501        Self::from_components(parts)
502    }
503
504    fn from_components(parts: camel_component_api::UriComponents) -> Result<Self, CamelError> {
505        if parts.scheme != Self::scheme() {
506            return Err(CamelError::InvalidUri(format!(
507                "unsupported scheme '{}', expected '{}'",
508                parts.scheme,
509                Self::scheme()
510            )));
511        }
512
513        fn parse_bool_param(
514            params: &std::collections::HashMap<String, String>,
515            key: &str,
516            default: bool,
517        ) -> Result<bool, CamelError> {
518            match params.get(key) {
519                Some(v) => match v.to_lowercase().as_str() {
520                    "true" | "1" | "yes" => Ok(true),
521                    "false" | "0" | "no" => Ok(false),
522                    _ => Err(CamelError::InvalidUri(format!(
523                        "invalid value for {key}: invalid boolean value: '{v}'"
524                    ))),
525                },
526                None => Ok(default),
527            }
528        }
529
530        fn parse_u64_param(
531            params: &std::collections::HashMap<String, String>,
532            key: &str,
533            default: u64,
534        ) -> Result<u64, CamelError> {
535            match params.get(key) {
536                Some(v) => v
537                    .parse::<u64>()
538                    .map_err(|e| CamelError::InvalidUri(format!("invalid value for {key}: {e}"))),
539                None => Ok(default),
540            }
541        }
542
543        fn parse_enum_param<T: FromStr>(
544            params: &std::collections::HashMap<String, String>,
545            key: &str,
546            default: &str,
547        ) -> Result<T, CamelError>
548        where
549            T::Err: std::fmt::Display,
550        {
551            let raw = params.get(key).map(String::as_str).unwrap_or(default);
552            raw.parse::<T>().map_err(|e| {
553                CamelError::InvalidUri(format!("invalid value for parameter '{key}': {e}"))
554            })
555        }
556
557        let params = &parts.params;
558        let delay_ms = parse_u64_param(params, "delay", 500)?;
559        let initial_delay_ms = parse_u64_param(params, "initialDelay", 1000)?;
560        let read_timeout_ms = parse_u64_param(params, "readTimeout", 30_000)?;
561        let write_timeout_ms = parse_u64_param(params, "writeTimeout", 30_000)?;
562
563        let cfg = Self {
564            directory: parts.path,
565            delay_ms,
566            delay: Duration::from_millis(delay_ms),
567            initial_delay_ms,
568            initial_delay: Duration::from_millis(initial_delay_ms),
569            noop: parse_bool_param(params, "noop", false)?,
570            delete: parse_bool_param(params, "delete", false)?,
571            move_to: params.get("move").cloned(),
572            file_name: params.get("fileName").cloned(),
573            include: params.get("include").cloned(),
574            exclude: params.get("exclude").cloned(),
575            recursive: parse_bool_param(params, "recursive", false)?,
576            file_exist: parse_enum_param(params, "fileExist", "Override")?,
577            read_lock_strategy: parse_enum_param(params, "readLock", "None")?,
578            idempotent_key: parse_enum_param(params, "idempotentKey", "None")?,
579            done_file_name: params.get("doneFileName").cloned(),
580            charset: params.get("charset").cloned(),
581            temp_prefix: params.get("tempPrefix").cloned(),
582            durable: parse_bool_param(params, "durable", false)?,
583            auto_create: parse_bool_param(params, "autoCreate", true)?,
584            starting_directory_must_exist: parse_bool_param(
585                params,
586                "startingDirectoryMustExist",
587                false,
588            )?,
589            read_timeout_ms,
590            read_timeout: Duration::from_millis(read_timeout_ms),
591            write_timeout_ms,
592            write_timeout: Duration::from_millis(write_timeout_ms),
593            max_depth: parse_u64_param(params, "maxDepth", u64::MAX)? as usize,
594            min_depth: parse_u64_param(params, "minDepth", 0)? as usize,
595            max_messages_per_poll: params
596                .get("maxMessagesPerPoll")
597                .map(|v| {
598                    v.parse::<i64>().map_err(|e| {
599                        CamelError::InvalidUri(format!("invalid value for maxMessagesPerPoll: {e}"))
600                    })
601                })
602                .transpose()?
603                .unwrap_or(0),
604            eager_max_messages_per_poll: parse_bool_param(params, "eagerMaxMessagesPerPoll", true)?,
605            ant_include: params.get("antInclude").cloned(),
606            ant_exclude: params.get("antExclude").cloned(),
607            include_ext: params.get("includeExt").cloned(),
608            exclude_ext: params.get("excludeExt").cloned(),
609            shuffle: parse_bool_param(params, "shuffle", false)?,
610            sort_spec: params
611                .get("sortBy")
612                .map(|v| v.parse::<SortSpec>())
613                .transpose()
614                .map_err(|e| CamelError::InvalidUri(format!("invalid sortBy: {e}")))?,
615        };
616
617        cfg.validate()
618    }
619
620    fn validate(self) -> Result<Self, CamelError> {
621        // Reject path traversal in move_to
622        if let Some(ref move_to) = self.move_to
623            && path_contains_traversal(move_to)
624        {
625            return Err(CamelError::Config(format!(
626                "move_to contains path traversal component: {move_to}"
627            )));
628        }
629
630        if let Some(ref move_to) = self.move_to
631            && std::path::Path::new(move_to).is_absolute()
632        {
633            return Err(CamelError::InvalidUri(format!(
634                "move_to must be relative path within base directory: {move_to}"
635            )));
636        }
637
638        // Reject path traversal in temp_prefix
639        if let Some(ref temp_prefix) = self.temp_prefix
640            && path_contains_traversal(temp_prefix)
641        {
642            return Err(CamelError::Config(format!(
643                "temp_prefix contains path traversal component: {temp_prefix}"
644            )));
645        }
646
647        if let Some(ref temp_prefix) = self.temp_prefix
648            && !is_valid_temp_prefix(temp_prefix)
649        {
650            return Err(CamelError::Config(
651                "temp_prefix must be plain filename prefix (no path separators, absolute paths, or null bytes)".into(),
652            ));
653        }
654
655        if self.file_exist == FileExistStrategy::TryRename && self.temp_prefix.is_none() {
656            return Err(CamelError::Config(
657                "fileExist=TryRename requires tempPrefix to be set".into(),
658            ));
659        }
660
661        // Reject empty file_name
662        if let Some(ref file_name) = self.file_name {
663            if file_name.is_empty() {
664                return Err(CamelError::Config("file_name must not be empty".into()));
665            }
666            // Reject null bytes in file_name
667            if file_name.contains('\0') {
668                return Err(CamelError::Config(
669                    "file_name must not contain null bytes".into(),
670                ));
671            }
672        }
673
674        if self.min_depth > self.max_depth {
675            return Err(CamelError::Config(
676                "minDepth cannot be greater than maxDepth".into(),
677            ));
678        }
679
680        // If starting_directory_must_exist, verify directory exists
681        if self.starting_directory_must_exist {
682            let dir_path = std::path::Path::new(&self.directory);
683            if !dir_path.exists() {
684                return Err(CamelError::Config(format!(
685                    "starting directory does not exist: {}",
686                    self.directory
687                )));
688            }
689        }
690
691        // Apply conditional logic for move_to:
692        // - If noop or delete is true, move_to should be None
693        // - Otherwise, if move_to is None, default to ".camel"
694        let move_to = if self.noop || self.delete {
695            None
696        } else {
697            Some(self.move_to.unwrap_or_else(|| ".camel".to_string()))
698        };
699
700        Ok(Self { move_to, ..self })
701    }
702}
703
704impl FileConfig {
705    /// Apply global config defaults. Since FileConfig uses a proc macro that bakes in
706    /// defaults, we compare Duration values against the known macro defaults to detect
707    /// "not explicitly set by user". Only overrides when current value == macro default.
708    ///
709    /// **Note**: If a user explicitly sets a URI param to its default value (e.g.,
710    /// `?delay=500`), it is indistinguishable from "not set" and will be overridden
711    /// by global config. This is a known limitation of the Duration comparison approach.
712    pub fn apply_global_defaults(&mut self, global: &FileGlobalConfig) {
713        if self.delay == Duration::from_millis(500) {
714            self.delay = Duration::from_millis(global.delay_ms);
715        }
716        if self.initial_delay == Duration::from_millis(1_000) {
717            self.initial_delay = Duration::from_millis(global.initial_delay_ms);
718        }
719        if self.read_timeout == Duration::from_millis(30_000) {
720            self.read_timeout = Duration::from_millis(global.read_timeout_ms);
721        }
722        if self.write_timeout == Duration::from_millis(30_000) {
723            self.write_timeout = Duration::from_millis(global.write_timeout_ms);
724        }
725    }
726}
727
728// ---------------------------------------------------------------------------
729// FileComponent
730// ---------------------------------------------------------------------------
731
732pub struct FileComponent {
733    config: Option<FileGlobalConfig>,
734}
735
736impl FileComponent {
737    pub fn new() -> Self {
738        Self { config: None }
739    }
740
741    pub fn with_config(config: FileGlobalConfig) -> Self {
742        Self {
743            config: Some(config),
744        }
745    }
746
747    pub fn with_optional_config(config: Option<FileGlobalConfig>) -> Self {
748        Self { config }
749    }
750}
751
752impl Default for FileComponent {
753    fn default() -> Self {
754        Self::new()
755    }
756}
757
758impl Component for FileComponent {
759    fn scheme(&self) -> &str {
760        "file"
761    }
762
763    fn create_endpoint(
764        &self,
765        uri: &str,
766        ctx: &dyn camel_component_api::ComponentContext,
767    ) -> Result<Box<dyn Endpoint>, CamelError> {
768        let mut config = FileConfig::from_uri(uri)?;
769        if let Some(ref global_config) = self.config {
770            config.apply_global_defaults(global_config);
771        }
772        let filters = CompiledFilters::compile(&config)?;
773        let dir_path = std::path::PathBuf::from(&config.directory);
774        let health_check = FileHealthCheck::new(dir_path.clone());
775        ctx.register_current_route_health_check(std::sync::Arc::new(health_check));
776        Ok(Box::new(FileEndpoint {
777            uri: uri.to_string(),
778            config,
779            filters,
780            in_process_locks: std::sync::Arc::new(DashMap::new()),
781            idempotent_repo: std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new())),
782        }))
783    }
784}
785
786// ---------------------------------------------------------------------------
787// FileEndpoint
788// ---------------------------------------------------------------------------
789
790struct FileEndpoint {
791    uri: String,
792    config: FileConfig,
793    filters: CompiledFilters,
794    in_process_locks: std::sync::Arc<DashMap<PathBuf, ()>>,
795    idempotent_repo: std::sync::Arc<tokio::sync::Mutex<HashSet<String>>>,
796}
797
798impl Endpoint for FileEndpoint {
799    fn uri(&self) -> &str {
800        &self.uri
801    }
802
803    fn create_consumer(
804        &self,
805        rt: Arc<dyn camel_component_api::RuntimeObservability>,
806    ) -> Result<Box<dyn Consumer>, CamelError> {
807        Ok(Box::new(FileConsumer::new(
808            self.config.clone(),
809            self.filters.clone(),
810            self.in_process_locks.clone(),
811            self.idempotent_repo.clone(),
812            rt,
813        )))
814    }
815
816    fn create_producer(
817        &self,
818        _rt: Arc<dyn camel_component_api::RuntimeObservability>,
819        _ctx: &ProducerContext,
820    ) -> Result<BoxProcessor, CamelError> {
821        Ok(BoxProcessor::new(FileProducer {
822            config: self.config.clone(),
823        }))
824    }
825
826    fn polling_consumer(&self) -> Option<Box<dyn PollingConsumer>> {
827        Some(Box::new(polling_consumer::FilePollingConsumer::new(
828            self.config.clone(),
829            self.in_process_locks.clone(),
830            self.idempotent_repo.clone(),
831            self.filters.clone(),
832        )))
833    }
834}
835
836// ---------------------------------------------------------------------------
837// FileConsumer
838// ---------------------------------------------------------------------------
839
840struct FileConsumer {
841    config: FileConfig,
842    filters: CompiledFilters,
843    seen: HashSet<PathBuf>,
844    in_process_locks: std::sync::Arc<DashMap<PathBuf, ()>>,
845    idempotent_repo: std::sync::Arc<tokio::sync::Mutex<HashSet<String>>>,
846    #[allow(dead_code)]
847    runtime: Arc<dyn camel_component_api::RuntimeObservability>,
848}
849
850impl FileConsumer {
851    fn new(
852        config: FileConfig,
853        filters: CompiledFilters,
854        in_process_locks: std::sync::Arc<DashMap<PathBuf, ()>>,
855        idempotent_repo: std::sync::Arc<tokio::sync::Mutex<HashSet<String>>>,
856        runtime: Arc<dyn camel_component_api::RuntimeObservability>,
857    ) -> Self {
858        Self {
859            config,
860            filters,
861            seen: HashSet::new(),
862            in_process_locks,
863            idempotent_repo,
864            runtime,
865        }
866    }
867}
868
869#[async_trait]
870impl Consumer for FileConsumer {
871    async fn start(&mut self, context: ConsumerContext) -> Result<(), CamelError> {
872        let config = self.config.clone();
873
874        if !config.initial_delay.is_zero() {
875            tokio::select! {
876                _ = time::sleep(config.initial_delay) => {}
877                _ = context.cancelled() => {
878                    debug!(directory = config.directory, "File consumer cancelled during initial delay");
879                    return Ok(());
880                }
881            }
882        }
883
884        let mut interval = time::interval(config.delay);
885
886        loop {
887            tokio::select! {
888                _ = context.cancelled() => {
889                    debug!(directory = config.directory, "File consumer received cancellation, stopping");
890                    break;
891                }
892                _ = interval.tick() => {
893                    if let Err(e) = poll_directory(
894                        &config,
895                        &context,
896                        &self.filters,
897                        &mut self.seen,
898                        &self.in_process_locks,
899                        &self.idempotent_repo,
900                    ).await {
901                        warn!(directory = config.directory, error = %e, "Error polling directory");
902                    }
903                }
904            }
905        }
906
907        Ok(())
908    }
909
910    async fn stop(&mut self) -> Result<(), CamelError> {
911        Ok(())
912    }
913}
914
915// ---------------------------------------------------------------------------
916// Path validation for security
917// ---------------------------------------------------------------------------
918
919/// Returns true if the path string contains a `..` component (path traversal).
920fn path_contains_traversal(path: &str) -> bool {
921    std::path::Path::new(path)
922        .components()
923        .any(|c| matches!(c, std::path::Component::ParentDir))
924}
925
926fn is_valid_temp_prefix(prefix: &str) -> bool {
927    !prefix.contains('\0')
928        && !std::path::Path::new(prefix).is_absolute()
929        && !prefix.contains(std::path::MAIN_SEPARATOR)
930        && !prefix.contains('/')
931        && !prefix.contains('\\')
932}
933
934fn validate_path_is_within_base(
935    base_dir: &std::path::Path,
936    target_path: &std::path::Path,
937) -> Result<(), CamelError> {
938    // If both base and target exist, use strict canonicalize comparison.
939    // Otherwise, do lexical traversal check (sufficient since config-time
940    // validation already rejects '..' in fileName).
941    if base_dir.exists() {
942        let canonical_base = base_dir.canonicalize().map_err(|e| {
943            CamelError::ProcessorError(format!("Cannot canonicalize base directory: {}", e))
944        })?;
945
946        let canonical_target = if target_path.exists() {
947            target_path.canonicalize().map_err(|e| {
948                CamelError::ProcessorError(format!("Cannot canonicalize target path: {}", e))
949            })?
950        } else if let Some(parent) = target_path.parent() {
951            if parent.exists() {
952                let canonical_parent = parent.canonicalize().map_err(|e| {
953                    CamelError::ProcessorError(format!(
954                        "Cannot canonicalize parent directory: {}",
955                        e
956                    ))
957                })?;
958                if let Some(filename) = target_path.file_name() {
959                    canonical_parent.join(filename)
960                } else {
961                    return Err(CamelError::ProcessorError(
962                        "Invalid target path: no filename".to_string(),
963                    ));
964                }
965            } else {
966                // Neither target nor its parent exist — use lexical traversal check.
967                let rel = target_path.strip_prefix(base_dir).map_err(|_| {
968                    CamelError::ProcessorError(format!(
969                        "Path '{}' is not under base '{}'",
970                        target_path.display(),
971                        base_dir.display()
972                    ))
973                })?;
974                if path_contains_traversal(&rel.to_string_lossy()) {
975                    return Err(CamelError::ProcessorError(format!(
976                        "Path '{}' contains directory traversal",
977                        target_path.display()
978                    )));
979                }
980                return Ok(());
981            }
982        } else {
983            return Err(CamelError::ProcessorError(
984                "Invalid target path: no parent directory".to_string(),
985            ));
986        };
987
988        if !canonical_target.starts_with(&canonical_base) {
989            return Err(CamelError::ProcessorError(format!(
990                "Path '{}' is outside base directory '{}'",
991                canonical_target.display(),
992                canonical_base.display()
993            )));
994        }
995    } else {
996        // Base dir doesn't exist yet (auto_create case).
997        // Lexical check: ensure no traversal in the relative portion.
998        let rel = target_path.strip_prefix(base_dir).map_err(|_| {
999            CamelError::ProcessorError(format!(
1000                "Path '{}' is not under base '{}'",
1001                target_path.display(),
1002                base_dir.display()
1003            ))
1004        })?;
1005        let rel_str = rel.to_string_lossy();
1006        if path_contains_traversal(&rel_str) {
1007            return Err(CamelError::ProcessorError(format!(
1008                "Path '{}' contains directory traversal",
1009                target_path.display()
1010            )));
1011        }
1012    }
1013
1014    Ok(())
1015}
1016
1017// ---------------------------------------------------------------------------
1018// FileProducer
1019// ---------------------------------------------------------------------------
1020
1021#[derive(Clone)]
1022struct FileProducer {
1023    config: FileConfig,
1024}
1025
1026impl FileProducer {
1027    async fn resolve_filename(
1028        exchange: &Exchange,
1029        config: &FileConfig,
1030    ) -> Result<String, CamelError> {
1031        let raw = if let Some(name) = exchange
1032            .input
1033            .header("CamelFileName")
1034            .and_then(|v| v.as_str())
1035        {
1036            Some(name.to_string())
1037        } else {
1038            config.file_name.clone()
1039        };
1040
1041        match raw {
1042            Some(name) if name.contains("${") => {
1043                let lang = SimpleLanguage::new();
1044                let expr = lang.create_expression(&name).map_err(|e| {
1045                    CamelError::ProcessorError(format!(
1046                        "cannot parse fileName expression '{}': {e}",
1047                        name
1048                    ))
1049                })?;
1050                let val = expr.evaluate(exchange).await.map_err(|e| {
1051                    CamelError::ProcessorError(format!(
1052                        "cannot evaluate fileName expression '{}': {e}",
1053                        name
1054                    ))
1055                })?;
1056                match val {
1057                    serde_json::Value::String(s) => Ok(s),
1058                    other => Ok(other.to_string()),
1059                }
1060            }
1061            Some(name) => Ok(name),
1062            None => Err(CamelError::ProcessorError(
1063                "No filename specified: set CamelFileName header or fileName option".to_string(),
1064            )),
1065        }
1066    }
1067}
1068
1069impl Service<Exchange> for FileProducer {
1070    type Response = Exchange;
1071    type Error = CamelError;
1072    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
1073
1074    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
1075        Poll::Ready(Ok(()))
1076    }
1077
1078    fn call(&mut self, mut exchange: Exchange) -> Self::Future {
1079        let config = self.config.clone();
1080
1081        Box::pin(async move {
1082            let file_name = FileProducer::resolve_filename(&exchange, &config).await?;
1083            let body = exchange.input.body.clone();
1084
1085            let dir_path = std::path::Path::new(&config.directory);
1086            let target_path = dir_path.join(&file_name);
1087
1088            // 1. Security: validate path is within base directory
1089            validate_path_is_within_base(dir_path, &target_path)?;
1090
1091            // 2. Auto-create directories (after path validation)
1092            if config.auto_create
1093                && let Some(parent) = target_path.parent()
1094            {
1095                tokio::time::timeout(config.write_timeout, fs::create_dir_all(parent))
1096                    .await
1097                    .map_err(|_| CamelError::ProcessorError("Timeout creating directories".into()))?
1098                    .map_err(CamelError::from)?;
1099            }
1100
1101            // 3. Handle file-exist strategy
1102            match config.file_exist {
1103                FileExistStrategy::Fail => {
1104                    let mut file = tokio::time::timeout(
1105                        config.write_timeout,
1106                        OpenOptions::new()
1107                            .write(true)
1108                            .create_new(true)
1109                            .open(&target_path),
1110                    )
1111                    .await
1112                    .map_err(|_| {
1113                        CamelError::ProcessorError("Timeout opening file with create_new".into())
1114                    })?
1115                    .map_err(CamelError::from)?;
1116
1117                    write_body_with_charset(body, &config.charset, &mut file, config.write_timeout)
1118                        .await?;
1119                    file.flush().await.map_err(CamelError::from)?;
1120                }
1121                FileExistStrategy::Ignore if target_path.exists() => return Ok(exchange),
1122                FileExistStrategy::Append => {
1123                    // Append: write directly without temp file (append is inherently non-atomic)
1124                    let mut file = tokio::time::timeout(
1125                        config.write_timeout,
1126                        OpenOptions::new()
1127                            .append(true)
1128                            .create(true)
1129                            .open(&target_path),
1130                    )
1131                    .await
1132                    .map_err(|_| {
1133                        CamelError::ProcessorError("Timeout opening file for append".into())
1134                    })?
1135                    .map_err(CamelError::from)?;
1136
1137                    write_body_with_charset(body, &config.charset, &mut file, config.write_timeout)
1138                        .await?;
1139
1140                    file.flush().await.map_err(CamelError::from)?;
1141                }
1142                FileExistStrategy::TryRename => {
1143                    // TryRename requires an explicit tempPrefix (validated at config time).
1144                    // Delegates to atomic_write so both branches share one rename path.
1145                    let prefix = config.temp_prefix.as_deref().ok_or_else(|| {
1146                        CamelError::Config("fileExist=TryRename requires tempPrefix".into())
1147                    })?;
1148                    crate::atomic_write::atomic_write(
1149                        &target_path,
1150                        body,
1151                        Some(prefix),
1152                        config.durable,
1153                        config.write_timeout,
1154                        &config.charset,
1155                    )
1156                    .await?;
1157                }
1158                _ => {
1159                    // Override (and Fail when file doesn't exist): always atomic via temp file.
1160                    // Delegates to atomic_write — fixes Bug C (rc-o6o.3): temp path was previously
1161                    // computed by joining dir_path with prefix+full_file_name, producing a path
1162                    // whose parent did not exist for nested fileName values.
1163                    crate::atomic_write::atomic_write(
1164                        &target_path,
1165                        body,
1166                        config.temp_prefix.as_deref(),
1167                        config.durable,
1168                        config.write_timeout,
1169                        &config.charset,
1170                    )
1171                    .await?;
1172                }
1173            }
1174
1175            if let Some(done_pattern) = &config.done_file_name {
1176                let done_name = done_pattern.replace("${file:name}", &file_name);
1177                tokio::time::timeout(
1178                    config.write_timeout,
1179                    fs::write(dir_path.join(done_name), []),
1180                )
1181                .await
1182                .map_err(|_| CamelError::ProcessorError("Timeout creating done file".into()))?
1183                .map_err(CamelError::from)?;
1184            }
1185
1186            // 4. Set output header
1187            let abs_path = target_path
1188                .canonicalize()
1189                .unwrap_or_else(|_| target_path.clone())
1190                .to_string_lossy()
1191                .to_string();
1192            exchange
1193                .input
1194                .set_header("CamelFileNameProduced", serde_json::Value::String(abs_path));
1195
1196            debug!(
1197                file = %target_path.display(),
1198                correlation_id = %exchange.correlation_id(),
1199                "File written"
1200            );
1201
1202            Ok(exchange)
1203        })
1204    }
1205}
1206
1207pub(crate) async fn write_body_with_charset(
1208    body: Body,
1209    charset: &Option<String>,
1210    file: &mut fs::File,
1211    timeout: Duration,
1212) -> Result<(), CamelError> {
1213    match body {
1214        Body::Text(text) | Body::Xml(text) => {
1215            let bytes = encode_text_by_charset(&text, charset)?;
1216            tokio::time::timeout(timeout, file.write_all(&bytes))
1217                .await
1218                .map_err(|_| CamelError::ProcessorError("Timeout writing file".into()))?
1219                .map_err(CamelError::from)?;
1220            Ok(())
1221        }
1222        other => {
1223            let mut reader = other.into_async_read()?;
1224            tokio::time::timeout(timeout, io::copy(&mut reader, file))
1225                .await
1226                .map_err(|_| CamelError::ProcessorError("Timeout writing to file".into()))?
1227                .map_err(|e| CamelError::ProcessorError(e.to_string()))?;
1228            Ok(())
1229        }
1230    }
1231}
1232
1233fn encode_text_by_charset(text: &str, charset: &Option<String>) -> Result<Vec<u8>, CamelError> {
1234    let Some(charset) = charset.as_ref() else {
1235        return Ok(text.as_bytes().to_vec());
1236    };
1237
1238    if charset.eq_ignore_ascii_case("utf-8") {
1239        return Ok(text.as_bytes().to_vec());
1240    }
1241
1242    if charset.eq_ignore_ascii_case("iso-8859-1") {
1243        let mut out = Vec::with_capacity(text.len());
1244        for ch in text.chars() {
1245            let code = ch as u32;
1246            if code > 0xFF {
1247                return Err(CamelError::ProcessorError(format!(
1248                    "character '{ch}' cannot be encoded as ISO-8859-1"
1249                )));
1250            }
1251            out.push(code as u8);
1252        }
1253        return Ok(out);
1254    }
1255
1256    Err(CamelError::Config(format!(
1257        "unsupported charset '{charset}', supported: UTF-8, ISO-8859-1"
1258    )))
1259}
1260
1261#[cfg(test)]
1262mod tests {
1263    use camel_component_api::test_support::PanicRuntimeObservability;
1264    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
1265        std::sync::Arc::new(PanicRuntimeObservability)
1266    }
1267
1268    use super::*;
1269    use crate::poll_logic::{
1270        ModificationDetectingStream, apply_sort_and_limit, list_files, poll_one_file,
1271        scan_candidates,
1272    };
1273    use bytes::Bytes;
1274    use camel_component_api::{Message, NoOpComponentContext, StreamBody, StreamMetadata};
1275    use futures::StreamExt;
1276    use std::time::Duration;
1277    use tokio_util::io::ReaderStream;
1278    use tokio_util::sync::CancellationToken;
1279
1280    fn test_producer_ctx() -> ProducerContext {
1281        ProducerContext::new()
1282    }
1283
1284    #[test]
1285    fn test_file_config_defaults() {
1286        let config = FileConfig::from_uri("file:/tmp/inbox").unwrap();
1287        assert_eq!(config.directory, "/tmp/inbox");
1288        assert_eq!(config.delay, Duration::from_millis(500));
1289        assert_eq!(config.initial_delay, Duration::from_millis(1000));
1290        assert!(!config.noop);
1291        assert!(!config.delete);
1292        assert_eq!(config.move_to, Some(".camel".to_string()));
1293        assert!(config.file_name.is_none());
1294        assert!(config.include.is_none());
1295        assert!(config.exclude.is_none());
1296        assert!(!config.recursive);
1297        assert_eq!(config.file_exist, FileExistStrategy::Override);
1298        assert!(config.temp_prefix.is_none());
1299        assert!(config.auto_create);
1300        // New timeout defaults
1301        assert_eq!(config.read_timeout, Duration::from_secs(30));
1302        assert_eq!(config.write_timeout, Duration::from_secs(30));
1303    }
1304
1305    #[test]
1306    fn file_config_durable_defaults_to_false() {
1307        let config = FileConfig::from_uri("file:/tmp/inbox").unwrap();
1308        assert!(!config.durable, "durable must default to false");
1309    }
1310
1311    #[test]
1312    fn file_config_durable_true_parsed_from_uri() {
1313        let config = FileConfig::from_uri("file:/tmp/inbox?durable=true").unwrap();
1314        assert!(config.durable, "durable=true must parse from URI");
1315    }
1316
1317    #[test]
1318    fn file_config_durable_rejects_invalid_value() {
1319        let result = FileConfig::from_uri("file:/tmp/inbox?durable=maybe");
1320        assert!(result.is_err(), "invalid durable value must be rejected");
1321    }
1322
1323    #[test]
1324    fn test_file_config_consumer_options() {
1325        let config = FileConfig::from_uri(
1326            "file:/data/input?delay=1000&initialDelay=2000&noop=true&recursive=true&include=.*\\.csv"
1327        ).unwrap();
1328        assert_eq!(config.directory, "/data/input");
1329        assert_eq!(config.delay, Duration::from_millis(1000));
1330        assert_eq!(config.initial_delay, Duration::from_millis(2000));
1331        assert!(config.noop);
1332        assert!(config.recursive);
1333        assert_eq!(config.include, Some(".*\\.csv".to_string()));
1334    }
1335
1336    #[test]
1337    fn test_file_config_producer_options() {
1338        let config = FileConfig::from_uri(
1339            "file:/data/output?fileExist=Append&tempPrefix=.tmp&autoCreate=false&fileName=out.txt",
1340        )
1341        .unwrap();
1342        assert_eq!(config.file_exist, FileExistStrategy::Append);
1343        assert_eq!(config.temp_prefix, Some(".tmp".to_string()));
1344        assert!(!config.auto_create);
1345        assert_eq!(config.file_name, Some("out.txt".to_string()));
1346    }
1347
1348    #[test]
1349    fn test_file_config_delete_mode() {
1350        let config = FileConfig::from_uri("file:/tmp/inbox?delete=true").unwrap();
1351        assert!(config.delete);
1352        assert!(config.move_to.is_none());
1353    }
1354
1355    #[test]
1356    fn test_file_config_noop_mode() {
1357        let config = FileConfig::from_uri("file:/tmp/inbox?noop=true").unwrap();
1358        assert!(config.noop);
1359        assert!(config.move_to.is_none());
1360    }
1361
1362    #[test]
1363    fn test_file_config_wrong_scheme() {
1364        let result = FileConfig::from_uri("timer:tick");
1365        assert!(result.is_err());
1366    }
1367
1368    #[test]
1369    fn test_min_depth_greater_than_max_depth_rejected() {
1370        let result = FileConfig::from_uri("file:///tmp?minDepth=5&maxDepth=2");
1371        assert!(result.is_err());
1372        assert!(
1373            result
1374                .unwrap_err()
1375                .to_string()
1376                .contains("minDepth cannot be greater")
1377        );
1378    }
1379
1380    #[test]
1381    fn test_file_component_scheme() {
1382        let component = FileComponent::new();
1383        assert_eq!(component.scheme(), "file");
1384    }
1385
1386    #[test]
1387    fn test_file_component_creates_endpoint() {
1388        let component = FileComponent::new();
1389        let ctx = NoOpComponentContext;
1390        let endpoint = component.create_endpoint("file:/tmp/test", &ctx);
1391        assert!(endpoint.is_ok());
1392    }
1393
1394    // -----------------------------------------------------------------------
1395    // Consumer tests
1396    // -----------------------------------------------------------------------
1397
1398    #[tokio::test]
1399    async fn test_file_consumer_reads_files() {
1400        let dir = tempfile::tempdir().unwrap();
1401        let dir_path = dir.path().to_str().unwrap();
1402
1403        std::fs::write(dir.path().join("test1.txt"), "hello").unwrap();
1404        std::fs::write(dir.path().join("test2.txt"), "world").unwrap();
1405
1406        let component = FileComponent::new();
1407        let ctx = NoOpComponentContext;
1408        let endpoint = component
1409            .create_endpoint(
1410                &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100"),
1411                &ctx,
1412            )
1413            .unwrap();
1414        let mut consumer = endpoint.create_consumer(rt()).unwrap();
1415
1416        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1417        let token = CancellationToken::new();
1418        let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
1419
1420        tokio::spawn(async move {
1421            consumer.start(ctx).await.unwrap();
1422        });
1423
1424        let mut received = Vec::new();
1425        let timeout = tokio::time::timeout(Duration::from_secs(2), async {
1426            while let Some(envelope) = rx.recv().await {
1427                received.push(envelope.exchange);
1428                if received.len() == 2 {
1429                    break;
1430                }
1431            }
1432        })
1433        .await;
1434        token.cancel();
1435
1436        assert!(timeout.is_ok(), "Should have received 2 exchanges");
1437        assert_eq!(received.len(), 2);
1438
1439        for ex in &received {
1440            assert!(ex.input.header("CamelFileName").is_some());
1441            assert!(ex.input.header("CamelFileNameOnly").is_some());
1442            assert!(ex.input.header("CamelFileAbsolutePath").is_some());
1443            assert!(ex.input.header("CamelFileLength").is_some());
1444            assert!(ex.input.header("CamelFileLastModified").is_some());
1445        }
1446    }
1447
1448    #[tokio::test]
1449    async fn noop_second_poll_does_not_re_emit_seen_files() {
1450        let dir = tempfile::tempdir().unwrap();
1451        let file_path = dir.path().join("test.txt");
1452        tokio::fs::write(&file_path, b"hello").await.unwrap();
1453
1454        let uri = format!(
1455            "file:{}?noop=true&initialDelay=0&delay=50",
1456            dir.path().display()
1457        );
1458        let config = FileConfig::from_uri(&uri).unwrap();
1459        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1460        let token = CancellationToken::new();
1461        let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
1462
1463        let filters = CompiledFilters::default();
1464        let mut seen = std::collections::HashSet::new();
1465        let in_process_locks = std::sync::Arc::new(DashMap::new());
1466        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
1467
1468        poll_directory(
1469            &config,
1470            &ctx,
1471            &filters,
1472            &mut seen,
1473            &in_process_locks,
1474            &idempotent_repo,
1475        )
1476        .await
1477        .unwrap();
1478        assert!(rx.try_recv().is_ok(), "first poll should emit file");
1479        assert!(rx.try_recv().is_err(), "should only emit once");
1480
1481        poll_directory(
1482            &config,
1483            &ctx,
1484            &filters,
1485            &mut seen,
1486            &in_process_locks,
1487            &idempotent_repo,
1488        )
1489        .await
1490        .unwrap();
1491        assert!(
1492            rx.try_recv().is_err(),
1493            "second poll should not re-emit seen file"
1494        );
1495    }
1496
1497    #[tokio::test]
1498    async fn noop_new_files_picked_up_after_first_poll() {
1499        let dir = tempfile::tempdir().unwrap();
1500        let file1 = dir.path().join("a.txt");
1501        tokio::fs::write(&file1, b"a").await.unwrap();
1502
1503        let uri = format!(
1504            "file:{}?noop=true&initialDelay=0&delay=50",
1505            dir.path().display()
1506        );
1507        let config = FileConfig::from_uri(&uri).unwrap();
1508        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1509        let token = CancellationToken::new();
1510        let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
1511
1512        let filters = CompiledFilters::default();
1513        let mut seen = std::collections::HashSet::new();
1514        let in_process_locks = std::sync::Arc::new(DashMap::new());
1515        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
1516
1517        poll_directory(
1518            &config,
1519            &ctx,
1520            &filters,
1521            &mut seen,
1522            &in_process_locks,
1523            &idempotent_repo,
1524        )
1525        .await
1526        .unwrap();
1527        let _ = rx.try_recv();
1528
1529        let file2 = dir.path().join("b.txt");
1530        tokio::fs::write(&file2, b"b").await.unwrap();
1531
1532        poll_directory(
1533            &config,
1534            &ctx,
1535            &filters,
1536            &mut seen,
1537            &in_process_locks,
1538            &idempotent_repo,
1539        )
1540        .await
1541        .unwrap();
1542        assert!(
1543            rx.try_recv().is_ok(),
1544            "b.txt should be emitted on second poll"
1545        );
1546        assert!(rx.try_recv().is_err(), "a.txt should not be re-emitted");
1547    }
1548
1549    #[tokio::test]
1550    async fn test_file_consumer_include_filter() {
1551        let dir = tempfile::tempdir().unwrap();
1552        let dir_path = dir.path().to_str().unwrap();
1553
1554        std::fs::write(dir.path().join("data.csv"), "a,b,c").unwrap();
1555        std::fs::write(dir.path().join("readme.txt"), "hello").unwrap();
1556
1557        let component = FileComponent::new();
1558        let ctx = NoOpComponentContext;
1559        let endpoint = component
1560            .create_endpoint(
1561                &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&include=.*\\.csv"),
1562                &ctx,
1563            )
1564            .unwrap();
1565        let mut consumer = endpoint.create_consumer(rt()).unwrap();
1566
1567        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1568        let token = CancellationToken::new();
1569        let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
1570
1571        tokio::spawn(async move {
1572            consumer.start(ctx).await.unwrap();
1573        });
1574
1575        let mut received = Vec::new();
1576        let _ = tokio::time::timeout(Duration::from_millis(500), async {
1577            while let Some(envelope) = rx.recv().await {
1578                received.push(envelope.exchange);
1579                if received.len() == 1 {
1580                    break;
1581                }
1582            }
1583        })
1584        .await;
1585        token.cancel();
1586
1587        assert_eq!(received.len(), 1);
1588        let name = received[0]
1589            .input
1590            .header("CamelFileNameOnly")
1591            .and_then(|v| v.as_str())
1592            .unwrap();
1593        assert_eq!(name, "data.csv");
1594    }
1595
1596    #[tokio::test]
1597    async fn test_file_consumer_delete_mode() {
1598        let dir = tempfile::tempdir().unwrap();
1599        let dir_path = dir.path().to_str().unwrap();
1600
1601        std::fs::write(dir.path().join("deleteme.txt"), "bye").unwrap();
1602
1603        let component = FileComponent::new();
1604        let ctx = NoOpComponentContext;
1605        let endpoint = component
1606            .create_endpoint(
1607                &format!("file:{dir_path}?delete=true&initialDelay=0&delay=100"),
1608                &ctx,
1609            )
1610            .unwrap();
1611        let mut consumer = endpoint.create_consumer(rt()).unwrap();
1612
1613        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1614        let token = CancellationToken::new();
1615        let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
1616
1617        tokio::spawn(async move {
1618            consumer.start(ctx).await.unwrap();
1619        });
1620
1621        let _ = tokio::time::timeout(Duration::from_millis(500), async { rx.recv().await }).await;
1622        token.cancel();
1623
1624        tokio::time::sleep(Duration::from_millis(100)).await;
1625
1626        assert!(
1627            !dir.path().join("deleteme.txt").exists(),
1628            "File should be deleted"
1629        );
1630    }
1631
1632    #[tokio::test]
1633    async fn test_file_consumer_move_mode() {
1634        let dir = tempfile::tempdir().unwrap();
1635        tokio::fs::write(dir.path().join("moveme.txt"), b"data")
1636            .await
1637            .unwrap();
1638
1639        let uri = format!("file:{}?initialDelay=0&delay=50", dir.path().display());
1640        let config = FileConfig::from_uri(&uri).unwrap();
1641        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1642        let token = CancellationToken::new();
1643        let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
1644
1645        let filters = CompiledFilters::default();
1646        let mut seen = std::collections::HashSet::new();
1647        let in_process_locks = std::sync::Arc::new(DashMap::new());
1648        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
1649
1650        poll_directory(
1651            &config,
1652            &ctx,
1653            &filters,
1654            &mut seen,
1655            &in_process_locks,
1656            &idempotent_repo,
1657        )
1658        .await
1659        .unwrap();
1660
1661        let ex = rx.try_recv().expect("should receive exchange");
1662        drop(ex);
1663
1664        assert!(
1665            !dir.path().join("moveme.txt").exists(),
1666            "Original file should be gone"
1667        );
1668        assert!(
1669            dir.path().join(".camel").join("moveme.txt").exists(),
1670            "File should be in .camel/"
1671        );
1672    }
1673
1674    #[tokio::test]
1675    async fn test_file_consumer_respects_cancellation() {
1676        let dir = tempfile::tempdir().unwrap();
1677        let dir_path = dir.path().to_str().unwrap();
1678
1679        let component = FileComponent::new();
1680        let ctx = NoOpComponentContext;
1681        let endpoint = component
1682            .create_endpoint(&format!("file:{dir_path}?initialDelay=0&delay=50"), &ctx)
1683            .unwrap();
1684        let mut consumer = endpoint.create_consumer(rt()).unwrap();
1685
1686        let (tx, _rx) = tokio::sync::mpsc::channel(16);
1687        let token = CancellationToken::new();
1688        let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
1689
1690        let handle = tokio::spawn(async move {
1691            consumer.start(ctx).await.unwrap();
1692        });
1693
1694        tokio::time::sleep(Duration::from_millis(150)).await;
1695        token.cancel();
1696
1697        let result = tokio::time::timeout(Duration::from_secs(1), handle).await;
1698        assert!(
1699            result.is_ok(),
1700            "Consumer should have stopped after cancellation"
1701        );
1702    }
1703
1704    // -----------------------------------------------------------------------
1705    // Producer tests
1706    // -----------------------------------------------------------------------
1707
1708    #[tokio::test]
1709    async fn test_file_producer_writes_file() {
1710        use tower::ServiceExt;
1711
1712        let dir = tempfile::tempdir().unwrap();
1713        let dir_path = dir.path().to_str().unwrap();
1714
1715        let component = FileComponent::new();
1716        let ctx = NoOpComponentContext;
1717        let endpoint = component
1718            .create_endpoint(&format!("file:{dir_path}"), &ctx)
1719            .unwrap();
1720        let ctx = test_producer_ctx();
1721        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1722
1723        let mut exchange = Exchange::new(Message::new("file content"));
1724        exchange.input.set_header(
1725            "CamelFileName",
1726            serde_json::Value::String("output.txt".to_string()),
1727        );
1728
1729        let result = producer.oneshot(exchange).await.unwrap();
1730
1731        let content = std::fs::read_to_string(dir.path().join("output.txt")).unwrap();
1732        assert_eq!(content, "file content");
1733
1734        assert!(result.input.header("CamelFileNameProduced").is_some());
1735    }
1736
1737    #[tokio::test]
1738    async fn test_file_producer_auto_create_dirs() {
1739        use tower::ServiceExt;
1740
1741        let dir = tempfile::tempdir().unwrap();
1742        let dir_path = dir.path().to_str().unwrap();
1743
1744        let component = FileComponent::new();
1745        let ctx = NoOpComponentContext;
1746        let endpoint = component
1747            .create_endpoint(&format!("file:{dir_path}/sub/dir"), &ctx)
1748            .unwrap();
1749        let ctx = test_producer_ctx();
1750        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1751
1752        let mut exchange = Exchange::new(Message::new("nested"));
1753        exchange.input.set_header(
1754            "CamelFileName",
1755            serde_json::Value::String("file.txt".to_string()),
1756        );
1757
1758        producer.oneshot(exchange).await.unwrap();
1759
1760        assert!(dir.path().join("sub/dir/file.txt").exists());
1761    }
1762
1763    #[tokio::test]
1764    async fn test_file_producer_file_exist_fail() {
1765        use tower::ServiceExt;
1766
1767        let dir = tempfile::tempdir().unwrap();
1768        let dir_path = dir.path().to_str().unwrap();
1769
1770        std::fs::write(dir.path().join("existing.txt"), "old").unwrap();
1771
1772        let component = FileComponent::new();
1773        let ctx = NoOpComponentContext;
1774        let endpoint = component
1775            .create_endpoint(&format!("file:{dir_path}?fileExist=Fail"), &ctx)
1776            .unwrap();
1777        let ctx = test_producer_ctx();
1778        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1779
1780        let mut exchange = Exchange::new(Message::new("new"));
1781        exchange.input.set_header(
1782            "CamelFileName",
1783            serde_json::Value::String("existing.txt".to_string()),
1784        );
1785
1786        let result = producer.oneshot(exchange).await;
1787        assert!(
1788            result.is_err(),
1789            "Should fail when file exists with Fail strategy"
1790        );
1791    }
1792
1793    #[tokio::test]
1794    async fn test_file_producer_file_exist_append() {
1795        use tower::ServiceExt;
1796
1797        let dir = tempfile::tempdir().unwrap();
1798        let dir_path = dir.path().to_str().unwrap();
1799
1800        std::fs::write(dir.path().join("append.txt"), "old").unwrap();
1801
1802        let component = FileComponent::new();
1803        let ctx = NoOpComponentContext;
1804        let endpoint = component
1805            .create_endpoint(&format!("file:{dir_path}?fileExist=Append"), &ctx)
1806            .unwrap();
1807        let ctx = test_producer_ctx();
1808        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1809
1810        let mut exchange = Exchange::new(Message::new("new"));
1811        exchange.input.set_header(
1812            "CamelFileName",
1813            serde_json::Value::String("append.txt".to_string()),
1814        );
1815
1816        producer.oneshot(exchange).await.unwrap();
1817
1818        let content = std::fs::read_to_string(dir.path().join("append.txt")).unwrap();
1819        assert_eq!(content, "oldnew");
1820    }
1821
1822    #[tokio::test]
1823    async fn test_file_producer_temp_prefix() {
1824        use tower::ServiceExt;
1825
1826        let dir = tempfile::tempdir().unwrap();
1827        let dir_path = dir.path().to_str().unwrap();
1828
1829        let component = FileComponent::new();
1830        let ctx = NoOpComponentContext;
1831        let endpoint = component
1832            .create_endpoint(&format!("file:{dir_path}?tempPrefix=.tmp"), &ctx)
1833            .unwrap();
1834        let ctx = test_producer_ctx();
1835        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1836
1837        let mut exchange = Exchange::new(Message::new("atomic write"));
1838        exchange.input.set_header(
1839            "CamelFileName",
1840            serde_json::Value::String("final.txt".to_string()),
1841        );
1842
1843        producer.oneshot(exchange).await.unwrap();
1844
1845        assert!(dir.path().join("final.txt").exists());
1846        assert!(!dir.path().join(".tmpfinal.txt").exists());
1847        let content = std::fs::read_to_string(dir.path().join("final.txt")).unwrap();
1848        assert_eq!(content, "atomic write");
1849    }
1850
1851    #[tokio::test]
1852    async fn file_producer_writes_nested_filename_override() {
1853        use tower::ServiceExt;
1854
1855        let dir = tempfile::tempdir().unwrap();
1856        let dir_path = dir.path().to_str().unwrap();
1857
1858        let component = FileComponent::new();
1859        let ctx = NoOpComponentContext;
1860        // Override is the default fileExist strategy.
1861        let endpoint = component
1862            .create_endpoint(&format!("file:{dir_path}"), &ctx)
1863            .unwrap();
1864        let ctx = test_producer_ctx();
1865        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1866
1867        let mut exchange = Exchange::new(Message::new("nested body"));
1868        exchange.input.set_header(
1869            "CamelFileName",
1870            serde_json::Value::String("sub/dir/payload.bin".to_string()),
1871        );
1872
1873        producer.oneshot(exchange).await.unwrap();
1874
1875        let target = dir.path().join("sub").join("dir").join("payload.bin");
1876        assert!(target.exists(), "target file should exist at {:?}", target);
1877        let content = std::fs::read(&target).unwrap();
1878        assert_eq!(content, b"nested body");
1879        // No leftover temp file in the directory root (Bug C symptom: .tmp.sub/dir/payload.bin
1880        // path was being computed and the parent dir did not exist).
1881        assert!(
1882            !dir.path().join(".tmp.sub").exists(),
1883            "no stray temp path should be created at directory root"
1884        );
1885    }
1886
1887    #[tokio::test]
1888    async fn file_producer_writes_nested_filename_try_rename() {
1889        use tower::ServiceExt;
1890
1891        let dir = tempfile::tempdir().unwrap();
1892        let dir_path = dir.path().to_str().unwrap();
1893
1894        let component = FileComponent::new();
1895        let ctx = NoOpComponentContext;
1896        let endpoint = component
1897            .create_endpoint(
1898                &format!("file:{dir_path}?fileExist=TryRename&tempPrefix=.t."),
1899                &ctx,
1900            )
1901            .unwrap();
1902        let ctx = test_producer_ctx();
1903        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1904
1905        let mut exchange = Exchange::new(Message::new("try-rename nested"));
1906        exchange.input.set_header(
1907            "CamelFileName",
1908            serde_json::Value::String("deep/dir/x.bin".to_string()),
1909        );
1910
1911        producer.oneshot(exchange).await.unwrap();
1912
1913        let target = dir.path().join("deep").join("dir").join("x.bin");
1914        assert!(target.exists());
1915        assert_eq!(std::fs::read(&target).unwrap(), b"try-rename nested");
1916        // Temp file must not leak.
1917        assert!(
1918            !dir.path()
1919                .join("deep")
1920                .join("dir")
1921                .join(".t.x.bin")
1922                .exists(),
1923            "temp file should have been renamed away"
1924        );
1925    }
1926
1927    #[tokio::test]
1928    async fn file_producer_writes_nested_filename_fail_strategy() {
1929        use tower::ServiceExt;
1930
1931        let dir = tempfile::tempdir().unwrap();
1932        let dir_path = dir.path().to_str().unwrap();
1933
1934        let component = FileComponent::new();
1935        let ctx = NoOpComponentContext;
1936        let endpoint = component
1937            .create_endpoint(&format!("file:{dir_path}?fileExist=Fail"), &ctx)
1938            .unwrap();
1939        let ctx = test_producer_ctx();
1940        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1941
1942        let mut exchange = Exchange::new(Message::new("fail-strategy nested"));
1943        exchange.input.set_header(
1944            "CamelFileName",
1945            serde_json::Value::String("a/b/c.bin".to_string()),
1946        );
1947
1948        producer.oneshot(exchange).await.unwrap();
1949
1950        let target = dir.path().join("a").join("b").join("c.bin");
1951        assert!(target.exists());
1952        assert_eq!(std::fs::read(&target).unwrap(), b"fail-strategy nested");
1953    }
1954
1955    #[tokio::test]
1956    async fn test_file_producer_uses_filename_option() {
1957        use tower::ServiceExt;
1958
1959        let dir = tempfile::tempdir().unwrap();
1960        let dir_path = dir.path().to_str().unwrap();
1961
1962        let component = FileComponent::new();
1963        let ctx = NoOpComponentContext;
1964        let endpoint = component
1965            .create_endpoint(&format!("file:{dir_path}?fileName=fixed.txt"), &ctx)
1966            .unwrap();
1967        let ctx = test_producer_ctx();
1968        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1969
1970        let exchange = Exchange::new(Message::new("content"));
1971
1972        producer.oneshot(exchange).await.unwrap();
1973        assert!(dir.path().join("fixed.txt").exists());
1974    }
1975
1976    #[tokio::test]
1977    async fn test_file_producer_no_filename_errors() {
1978        use tower::ServiceExt;
1979
1980        let dir = tempfile::tempdir().unwrap();
1981        let dir_path = dir.path().to_str().unwrap();
1982
1983        let component = FileComponent::new();
1984        let ctx = NoOpComponentContext;
1985        let endpoint = component
1986            .create_endpoint(&format!("file:{dir_path}"), &ctx)
1987            .unwrap();
1988        let ctx = test_producer_ctx();
1989        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
1990
1991        let exchange = Exchange::new(Message::new("content"));
1992
1993        let result = producer.oneshot(exchange).await;
1994        assert!(result.is_err(), "Should error when no filename is provided");
1995    }
1996
1997    // -----------------------------------------------------------------------
1998    // Security tests - Path traversal protection
1999    // -----------------------------------------------------------------------
2000
2001    #[tokio::test]
2002    async fn test_file_producer_rejects_path_traversal_parent_directory() {
2003        use tower::ServiceExt;
2004
2005        let dir = tempfile::tempdir().unwrap();
2006        let dir_path = dir.path().to_str().unwrap();
2007
2008        // Create a subdirectory
2009        std::fs::create_dir(dir.path().join("subdir")).unwrap();
2010        std::fs::write(dir.path().join("secret.txt"), "secret").unwrap();
2011
2012        let component = FileComponent::new();
2013        let ctx = NoOpComponentContext;
2014        let endpoint = component
2015            .create_endpoint(&format!("file:{dir_path}/subdir"), &ctx)
2016            .unwrap();
2017        let ctx = test_producer_ctx();
2018        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2019
2020        let mut exchange = Exchange::new(Message::new("malicious"));
2021        exchange.input.set_header(
2022            "CamelFileName",
2023            serde_json::Value::String("../secret.txt".to_string()),
2024        );
2025
2026        let result = producer.oneshot(exchange).await;
2027        assert!(result.is_err(), "Should reject path traversal attempt");
2028
2029        let err = result.unwrap_err();
2030        assert!(
2031            err.to_string().contains("outside"),
2032            "Error should mention path is outside base directory"
2033        );
2034    }
2035
2036    #[tokio::test]
2037    async fn test_file_producer_rejects_absolute_path_outside_base() {
2038        use tower::ServiceExt;
2039
2040        let dir = tempfile::tempdir().unwrap();
2041        let dir_path = dir.path().to_str().unwrap();
2042
2043        let component = FileComponent::new();
2044        let ctx = NoOpComponentContext;
2045        let endpoint = component
2046            .create_endpoint(&format!("file:{dir_path}"), &ctx)
2047            .unwrap();
2048        let ctx = test_producer_ctx();
2049        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2050
2051        let mut exchange = Exchange::new(Message::new("malicious"));
2052        exchange.input.set_header(
2053            "CamelFileName",
2054            serde_json::Value::String("/etc/passwd".to_string()),
2055        );
2056
2057        let result = producer.oneshot(exchange).await;
2058        assert!(result.is_err(), "Should reject absolute path outside base");
2059    }
2060
2061    #[tokio::test]
2062    async fn test_file_producer_does_not_create_dirs_before_path_validation() {
2063        use tower::ServiceExt;
2064
2065        let dir = tempfile::tempdir().unwrap();
2066        let dir_path = dir.path().to_str().unwrap();
2067
2068        let component = FileComponent::new();
2069        let ctx = NoOpComponentContext;
2070        let endpoint = component
2071            .create_endpoint(&format!("file:{dir_path}"), &ctx)
2072            .unwrap();
2073        let ctx = test_producer_ctx();
2074        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2075
2076        let outside_parent = dir.path().parent().unwrap().join("escaped-create-dir");
2077        if outside_parent.exists() {
2078            std::fs::remove_dir_all(&outside_parent).unwrap();
2079        }
2080
2081        let mut exchange = Exchange::new(Message::new("malicious"));
2082        exchange.input.set_header(
2083            "CamelFileName",
2084            serde_json::Value::String("../escaped-create-dir/file.txt".to_string()),
2085        );
2086
2087        let result = producer.oneshot(exchange).await;
2088        assert!(result.is_err(), "Should reject traversal path");
2089        assert!(
2090            !outside_parent.exists(),
2091            "Must not create outside directories before path validation"
2092        );
2093    }
2094
2095    #[cfg(unix)]
2096    #[tokio::test]
2097    async fn test_list_files_skips_symlink_cycle() {
2098        use std::os::unix::fs as unix_fs;
2099
2100        let dir = tempfile::tempdir().unwrap();
2101        let nested = dir.path().join("nested");
2102        std::fs::create_dir_all(&nested).unwrap();
2103        std::fs::write(nested.join("a.txt"), "x").unwrap();
2104        unix_fs::symlink(dir.path(), nested.join("loop")).unwrap();
2105
2106        let files = list_files(dir.path(), true).await.unwrap();
2107        assert_eq!(files.iter().filter(|p| p.ends_with("a.txt")).count(), 1);
2108    }
2109
2110    // -----------------------------------------------------------------------
2111    // Large file streaming tests
2112    // -----------------------------------------------------------------------
2113
2114    #[tokio::test]
2115    #[ignore] // Slow test - run with --ignored flag
2116    async fn test_large_file_streaming_constant_memory() {
2117        use std::io::Write;
2118        use tempfile::NamedTempFile;
2119
2120        // Create a 150MB file (larger than 100MB limit)
2121        let mut temp_file = NamedTempFile::new().unwrap();
2122        let file_size = 150 * 1024 * 1024; // 150MB
2123        let chunk = vec![b'X'; 1024 * 1024]; // 1MB chunk
2124
2125        for _ in 0..150 {
2126            temp_file.write_all(&chunk).unwrap();
2127        }
2128        temp_file.flush().unwrap();
2129
2130        let dir = temp_file.path().parent().unwrap();
2131        let dir_path = dir.to_str().unwrap();
2132        let file_name = temp_file
2133            .path()
2134            .file_name()
2135            .unwrap()
2136            .to_str()
2137            .unwrap()
2138            .to_string();
2139
2140        // Read file as stream (should succeed with lazy evaluation)
2141        let component = FileComponent::new();
2142        let component_ctx = NoOpComponentContext;
2143        let endpoint = component
2144            .create_endpoint(
2145                &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&fileName={file_name}"),
2146                &component_ctx,
2147            )
2148            .unwrap();
2149        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2150
2151        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
2152        let token = CancellationToken::new();
2153        let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
2154
2155        tokio::spawn(async move {
2156            let _ = consumer.start(ctx).await;
2157        });
2158
2159        let exchange = tokio::time::timeout(Duration::from_secs(5), async {
2160            rx.recv().await.unwrap().exchange
2161        })
2162        .await
2163        .expect("Should receive exchange");
2164        token.cancel();
2165
2166        // Verify body is a stream (not materialized)
2167        assert!(matches!(exchange.input.body, Body::Stream(_)));
2168
2169        // Verify we can read metadata without consuming
2170        if let Body::Stream(ref stream_body) = exchange.input.body {
2171            assert!(stream_body.metadata.size_hint.is_some());
2172            let size = stream_body.metadata.size_hint.unwrap();
2173            assert_eq!(size, file_size as u64);
2174        }
2175
2176        // Materializing should fail (exceeds 100MB limit)
2177        if let Body::Stream(stream_body) = exchange.input.body {
2178            let body = Body::Stream(stream_body);
2179            let result = body.into_bytes(100 * 1024 * 1024).await;
2180            assert!(result.is_err());
2181        }
2182
2183        // But we CAN read chunks one at a time (simulating line-by-line processing)
2184        // This demonstrates lazy evaluation - we don't need to load entire file
2185        let component2 = FileComponent::new();
2186        let endpoint2 = component2
2187            .create_endpoint(
2188                &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&fileName={file_name}"),
2189                &component_ctx,
2190            )
2191            .unwrap();
2192        let mut consumer2 = endpoint2.create_consumer(rt()).unwrap();
2193
2194        let (tx2, mut rx2) = tokio::sync::mpsc::channel(16);
2195        let token2 = CancellationToken::new();
2196        let ctx2 = ConsumerContext::new(tx2, token2.clone(), "file-test-route-2".to_string());
2197
2198        tokio::spawn(async move {
2199            let _ = consumer2.start(ctx2).await;
2200        });
2201
2202        let exchange2 = tokio::time::timeout(Duration::from_secs(5), async {
2203            rx2.recv().await.unwrap().exchange
2204        })
2205        .await
2206        .expect("Should receive exchange");
2207        token2.cancel();
2208
2209        if let Body::Stream(stream_body) = exchange2.input.body {
2210            let mut stream_lock = stream_body.stream.lock().await;
2211            let mut stream = stream_lock.take().unwrap();
2212
2213            // Read first chunk (size varies based on ReaderStream's buffer)
2214            if let Some(chunk_result) = stream.next().await {
2215                let chunk = chunk_result.unwrap();
2216                assert!(!chunk.is_empty());
2217                assert!(chunk.len() < file_size);
2218                // Memory usage is constant - we only have this chunk in memory, not 150MB
2219            }
2220        }
2221    }
2222
2223    // -----------------------------------------------------------------------
2224    // Streaming producer tests
2225    // -----------------------------------------------------------------------
2226
2227    #[tokio::test]
2228    async fn test_producer_writes_stream_body() {
2229        let dir = tempfile::tempdir().unwrap();
2230        let dir_path = dir.path().to_str().unwrap();
2231        let uri = format!("file:{dir_path}?fileName=out.txt");
2232
2233        let component = FileComponent::new();
2234        let ctx = NoOpComponentContext;
2235        let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2236        let producer = endpoint
2237            .create_producer(rt(), &test_producer_ctx())
2238            .unwrap();
2239
2240        let chunks: Vec<Result<Bytes, CamelError>> = vec![
2241            Ok(Bytes::from("hello ")),
2242            Ok(Bytes::from("streaming ")),
2243            Ok(Bytes::from("world")),
2244        ];
2245        let stream = futures::stream::iter(chunks);
2246        let body = Body::Stream(StreamBody {
2247            stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2248            metadata: StreamMetadata {
2249                size_hint: None,
2250                content_type: None,
2251                origin: None,
2252            },
2253        });
2254
2255        let exchange = Exchange::new(Message::new(body));
2256        tower::ServiceExt::oneshot(producer, exchange)
2257            .await
2258            .unwrap();
2259
2260        let content = tokio::fs::read_to_string(format!("{dir_path}/out.txt"))
2261            .await
2262            .unwrap();
2263        assert_eq!(content, "hello streaming world");
2264    }
2265
2266    #[tokio::test]
2267    async fn test_producer_stream_atomic_no_partial_on_error() {
2268        // If the stream errors mid-write, no file should exist at the target path
2269        let dir = tempfile::tempdir().unwrap();
2270        let dir_path = dir.path().to_str().unwrap();
2271        let uri = format!("file:{dir_path}?fileName=out.txt");
2272
2273        let component = FileComponent::new();
2274        let ctx = NoOpComponentContext;
2275        let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2276        let producer = endpoint
2277            .create_producer(rt(), &test_producer_ctx())
2278            .unwrap();
2279
2280        let chunks: Vec<Result<Bytes, CamelError>> = vec![
2281            Ok(Bytes::from("partial")),
2282            Err(CamelError::ProcessorError(
2283                "simulated stream error".to_string(),
2284            )),
2285        ];
2286        let stream = futures::stream::iter(chunks);
2287        let body = Body::Stream(StreamBody {
2288            stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2289            metadata: StreamMetadata {
2290                size_hint: None,
2291                content_type: None,
2292                origin: None,
2293            },
2294        });
2295
2296        let exchange = Exchange::new(Message::new(body));
2297        let result = tower::ServiceExt::oneshot(producer, exchange).await;
2298        assert!(
2299            result.is_err(),
2300            "expected error when stream fails mid-write"
2301        );
2302
2303        // Target file must NOT exist — write was aborted and temp file cleaned up
2304        assert!(
2305            !std::path::Path::new(&format!("{dir_path}/out.txt")).exists(),
2306            "partial file must not exist after failed write"
2307        );
2308
2309        // Temp file must also be cleaned up
2310        assert!(
2311            !std::path::Path::new(&format!("{dir_path}/.tmp.out.txt")).exists(),
2312            "temp file must be cleaned up after failed write"
2313        );
2314    }
2315
2316    #[tokio::test]
2317    async fn test_producer_stream_append() {
2318        let dir = tempfile::tempdir().unwrap();
2319        let dir_path = dir.path().to_str().unwrap();
2320        let target = format!("{dir_path}/out.txt");
2321
2322        // Pre-create file with initial content
2323        tokio::fs::write(&target, b"line1\n").await.unwrap();
2324
2325        let uri = format!("file:{dir_path}?fileName=out.txt&fileExist=Append");
2326        let component = FileComponent::new();
2327        let ctx = NoOpComponentContext;
2328        let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2329        let producer = endpoint
2330            .create_producer(rt(), &test_producer_ctx())
2331            .unwrap();
2332
2333        let chunks: Vec<Result<Bytes, CamelError>> = vec![Ok(Bytes::from("line2\n"))];
2334        let stream = futures::stream::iter(chunks);
2335        let body = Body::Stream(StreamBody {
2336            stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2337            metadata: StreamMetadata {
2338                size_hint: None,
2339                content_type: None,
2340                origin: None,
2341            },
2342        });
2343
2344        let exchange = Exchange::new(Message::new(body));
2345        tower::ServiceExt::oneshot(producer, exchange)
2346            .await
2347            .unwrap();
2348
2349        let content = tokio::fs::read_to_string(&target).await.unwrap();
2350        assert_eq!(content, "line1\nline2\n");
2351    }
2352
2353    #[tokio::test]
2354    async fn test_producer_stream_append_partial_on_error() {
2355        // Append is inherently non-atomic: if the stream errors mid-write,
2356        // the file will contain partial data. This test documents that behavior.
2357        let dir = tempfile::tempdir().unwrap();
2358        let dir_path = dir.path().to_str().unwrap();
2359        let target = format!("{dir_path}/out.txt");
2360
2361        // Pre-create file with initial content
2362        tokio::fs::write(&target, b"initial\n").await.unwrap();
2363
2364        let uri = format!("file:{dir_path}?fileName=out.txt&fileExist=Append");
2365        let component = FileComponent::new();
2366        let ctx = NoOpComponentContext;
2367        let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2368        let producer = endpoint
2369            .create_producer(rt(), &test_producer_ctx())
2370            .unwrap();
2371
2372        // Stream with an error in the middle
2373        let chunks: Vec<Result<Bytes, CamelError>> = vec![
2374            Ok(Bytes::from("partial-")), // This will be written
2375            Err(CamelError::ProcessorError("stream error".to_string())), // This causes failure
2376            Ok(Bytes::from("never-written")), // This won't be reached
2377        ];
2378        let stream = futures::stream::iter(chunks);
2379        let body = Body::Stream(StreamBody {
2380            stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2381            metadata: StreamMetadata {
2382                size_hint: None,
2383                content_type: None,
2384                origin: None,
2385            },
2386        });
2387
2388        let exchange = Exchange::new(Message::new(body));
2389        let result = tower::ServiceExt::oneshot(producer, exchange).await;
2390
2391        // 1. Producer must return an error
2392        assert!(
2393            result.is_err(),
2394            "expected error when stream fails during append"
2395        );
2396
2397        // 2. File must contain initial content + partial data written before the error
2398        let content = tokio::fs::read_to_string(&target).await.unwrap();
2399        assert_eq!(
2400            content, "initial\npartial-",
2401            "append leaves partial data on stream error (non-atomic by nature)"
2402        );
2403    }
2404
2405    #[tokio::test]
2406    async fn test_producer_stream_already_consumed_errors() {
2407        let dir = tempfile::tempdir().unwrap();
2408        let dir_path = dir.path().to_str().unwrap();
2409        let uri = format!("file:{dir_path}?fileName=out.txt");
2410
2411        let component = FileComponent::new();
2412        let ctx = NoOpComponentContext;
2413        let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2414        let producer = endpoint
2415            .create_producer(rt(), &test_producer_ctx())
2416            .unwrap();
2417
2418        // Mutex holds None -> stream already consumed
2419        type MaybeStream = std::sync::Arc<
2420            tokio::sync::Mutex<
2421                Option<
2422                    std::pin::Pin<
2423                        Box<dyn futures::Stream<Item = Result<Bytes, CamelError>> + Send>,
2424                    >,
2425                >,
2426            >,
2427        >;
2428        let arc: MaybeStream = std::sync::Arc::new(tokio::sync::Mutex::new(None));
2429        let body = Body::Stream(StreamBody {
2430            stream: arc,
2431            metadata: StreamMetadata {
2432                size_hint: None,
2433                content_type: None,
2434                origin: None,
2435            },
2436        });
2437
2438        let exchange = Exchange::new(Message::new(body));
2439        let result = tower::ServiceExt::oneshot(producer, exchange).await;
2440        assert!(
2441            result.is_err(),
2442            "expected error for already-consumed stream"
2443        );
2444    }
2445
2446    // -----------------------------------------------------------------------
2447    // GlobalConfig tests - apply_global_defaults behavior
2448    // -----------------------------------------------------------------------
2449
2450    #[test]
2451    fn test_global_config_applied_to_endpoint() {
2452        // Global config with non-default values
2453        let global = FileGlobalConfig::default()
2454            .with_delay_ms(2000)
2455            .with_initial_delay_ms(5000)
2456            .with_read_timeout_ms(60_000)
2457            .with_write_timeout_ms(45_000);
2458        let component = FileComponent::with_config(global);
2459        let ctx = NoOpComponentContext;
2460        // URI uses no explicit delay/timeout params → macro defaults apply
2461        let endpoint = component.create_endpoint("file:/tmp/inbox", &ctx).unwrap();
2462        // We cannot call endpoint.config directly (FileEndpoint is private),
2463        // but we can test apply_global_defaults on FileConfig directly:
2464        let mut config = FileConfig::from_uri("file:/tmp/inbox").unwrap();
2465        let global2 = FileGlobalConfig::default()
2466            .with_delay_ms(2000)
2467            .with_initial_delay_ms(5000)
2468            .with_read_timeout_ms(60_000)
2469            .with_write_timeout_ms(45_000);
2470        config.apply_global_defaults(&global2);
2471        assert_eq!(config.delay, Duration::from_millis(2000));
2472        assert_eq!(config.initial_delay, Duration::from_millis(5000));
2473        assert_eq!(config.read_timeout, Duration::from_millis(60_000));
2474        assert_eq!(config.write_timeout, Duration::from_millis(45_000));
2475        // endpoint creation succeeds too
2476        let _ = endpoint; // just verify create_endpoint didn't fail
2477    }
2478
2479    #[test]
2480    fn test_uri_param_wins_over_global_config() {
2481        // URI explicitly sets delay=1000 (NOT the 500ms macro default)
2482        let mut config =
2483            FileConfig::from_uri("file:/tmp/inbox?delay=1000&initialDelay=2000").unwrap();
2484        // Global config would want 3000ms delay
2485        let global = FileGlobalConfig::default()
2486            .with_delay_ms(3000)
2487            .with_initial_delay_ms(4000);
2488        config.apply_global_defaults(&global);
2489        // URI value of 1000ms must be preserved (not replaced by 3000ms)
2490        assert_eq!(config.delay, Duration::from_millis(1000));
2491        // URI value of 2000ms must be preserved (not replaced by 4000ms)
2492        assert_eq!(config.initial_delay, Duration::from_millis(2000));
2493        // read_timeout was not set by URI → macro default (30000) → global wins if different
2494        // (read_timeout stays at 30000 since global has same default = 30000)
2495        assert_eq!(config.read_timeout, Duration::from_millis(30_000));
2496    }
2497
2498    #[tokio::test]
2499    async fn test_file_producer_filename_simple_language_from_header() {
2500        use tower::ServiceExt;
2501
2502        let dir = tempfile::tempdir().unwrap();
2503        let dir_path = dir.path().to_str().unwrap();
2504
2505        let component = FileComponent::new();
2506        let ctx = NoOpComponentContext;
2507        let endpoint = component
2508            .create_endpoint(&format!("file:{dir_path}"), &ctx)
2509            .unwrap();
2510        let ctx = test_producer_ctx();
2511        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2512
2513        let mut exchange = Exchange::new(Message::new("content"));
2514        exchange
2515            .input
2516            .set_header("CamelTimerCounter", serde_json::Value::Number(42.into()));
2517        exchange.input.set_header(
2518            "CamelFileName",
2519            serde_json::Value::String("test-${header.CamelTimerCounter}.txt".to_string()),
2520        );
2521
2522        producer.oneshot(exchange).await.unwrap();
2523
2524        assert!(
2525            dir.path().join("test-42.txt").exists(),
2526            "fileName should have been evaluated from Simple Language expression"
2527        );
2528        let content = std::fs::read_to_string(dir.path().join("test-42.txt")).unwrap();
2529        assert_eq!(content, "content");
2530    }
2531
2532    #[tokio::test]
2533    async fn test_file_producer_filename_simple_language_from_uri_param() {
2534        use tower::ServiceExt;
2535
2536        let dir = tempfile::tempdir().unwrap();
2537        let dir_path = dir.path().to_str().unwrap();
2538
2539        let component = FileComponent::new();
2540        let ctx = NoOpComponentContext;
2541        let endpoint = component
2542            .create_endpoint(
2543                &format!("file:{dir_path}?fileName=msg-${{header.id}}.dat"),
2544                &ctx,
2545            )
2546            .unwrap();
2547        let ctx = test_producer_ctx();
2548        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2549
2550        let mut exchange = Exchange::new(Message::new("data"));
2551        exchange
2552            .input
2553            .set_header("id", serde_json::Value::String("abc".to_string()));
2554
2555        producer.oneshot(exchange).await.unwrap();
2556
2557        assert!(
2558            dir.path().join("msg-abc.dat").exists(),
2559            "fileName URI param should have been evaluated from Simple Language expression"
2560        );
2561    }
2562
2563    #[tokio::test]
2564    async fn test_file_producer_filename_literal_without_expression() {
2565        use tower::ServiceExt;
2566
2567        let dir = tempfile::tempdir().unwrap();
2568        let dir_path = dir.path().to_str().unwrap();
2569
2570        let component = FileComponent::new();
2571        let ctx = NoOpComponentContext;
2572        let endpoint = component
2573            .create_endpoint(&format!("file:{dir_path}?fileName=plain.txt"), &ctx)
2574            .unwrap();
2575        let ctx = test_producer_ctx();
2576        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2577
2578        let exchange = Exchange::new(Message::new("data"));
2579        producer.oneshot(exchange).await.unwrap();
2580
2581        assert!(
2582            dir.path().join("plain.txt").exists(),
2583            "literal fileName without expressions should still work"
2584        );
2585    }
2586
2587    // -----------------------------------------------------------------------
2588    // Config validation tests
2589    // -----------------------------------------------------------------------
2590
2591    #[test]
2592    fn test_rejects_path_traversal_in_move_to() {
2593        let result = FileConfig::from_uri("file:/tmp/inbox?move=../etc/passwd");
2594        assert!(result.is_err(), "should reject path traversal in move_to");
2595        let err = result.unwrap_err().to_string();
2596        assert!(
2597            err.contains("path traversal"),
2598            "error should mention path traversal: {err}"
2599        );
2600    }
2601
2602    #[test]
2603    fn test_rejects_absolute_move_to() {
2604        let result = FileConfig::from_uri("file:/tmp/inbox?move=/tmp/outside");
2605        assert!(result.is_err(), "should reject absolute move_to");
2606        let err = result.unwrap_err().to_string();
2607        assert!(
2608            err.contains("relative path") || err.contains("Invalid URI"),
2609            "error should mention invalid move_to path: {err}"
2610        );
2611    }
2612
2613    #[test]
2614    fn test_rejects_path_traversal_in_temp_prefix() {
2615        let result = FileConfig::from_uri("file:/tmp/inbox?tempPrefix=../tmp");
2616        assert!(
2617            result.is_err(),
2618            "should reject path traversal in temp_prefix"
2619        );
2620        let err = result.unwrap_err().to_string();
2621        assert!(
2622            err.contains("path traversal"),
2623            "error should mention path traversal: {err}"
2624        );
2625    }
2626
2627    #[test]
2628    fn test_rejects_temp_prefix_with_path_separator() {
2629        let result = FileConfig::from_uri("file:/tmp/inbox?tempPrefix=tmp/sub");
2630        assert!(result.is_err(), "should reject temp_prefix with separator");
2631        let err = result.unwrap_err().to_string();
2632        assert!(
2633            err.contains("plain filename prefix"),
2634            "error should mention plain filename prefix restriction: {err}"
2635        );
2636    }
2637
2638    #[test]
2639    fn test_rejects_absolute_temp_prefix() {
2640        let result = FileConfig::from_uri("file:/tmp/inbox?tempPrefix=/tmp/");
2641        assert!(result.is_err(), "should reject absolute temp_prefix");
2642        let err = result.unwrap_err().to_string();
2643        assert!(
2644            err.contains("plain filename prefix"),
2645            "error should mention plain filename prefix restriction: {err}"
2646        );
2647    }
2648
2649    #[test]
2650    fn test_rejects_null_byte_in_temp_prefix() {
2651        let config = FileConfig {
2652            directory: "/tmp/inbox".into(),
2653            delay: Duration::from_millis(500),
2654            delay_ms: 500,
2655            initial_delay: Duration::from_millis(1000),
2656            initial_delay_ms: 1000,
2657            noop: false,
2658            delete: false,
2659            move_to: None,
2660            file_name: Some("ok.txt".into()),
2661            include: None,
2662            exclude: None,
2663            ant_include: None,
2664            ant_exclude: None,
2665            include_ext: None,
2666            exclude_ext: None,
2667            recursive: false,
2668            file_exist: FileExistStrategy::Override,
2669            read_lock_strategy: ReadLockStrategy::None,
2670            idempotent_key: IdempotentKey::None,
2671            done_file_name: None,
2672            charset: None,
2673            temp_prefix: Some("tmp\0".into()),
2674            durable: false,
2675            auto_create: true,
2676            starting_directory_must_exist: false,
2677            read_timeout: Duration::from_millis(30_000),
2678            read_timeout_ms: 30_000,
2679            write_timeout: Duration::from_millis(30_000),
2680            write_timeout_ms: 30_000,
2681            max_depth: usize::MAX,
2682            min_depth: 0,
2683            max_messages_per_poll: 0,
2684            eager_max_messages_per_poll: true,
2685            shuffle: false,
2686            sort_spec: None,
2687        };
2688
2689        let result = config.validate();
2690        assert!(result.is_err(), "should reject null byte in temp_prefix");
2691    }
2692
2693    #[test]
2694    fn test_rejects_null_byte_in_filename() {
2695        // Null bytes in URI params are typically URL-encoded, so we test via
2696        // direct config construction to simulate the validation logic.
2697        let config = FileConfig {
2698            directory: "/tmp/inbox".into(),
2699            delay: Duration::from_millis(500),
2700            delay_ms: 500,
2701            initial_delay: Duration::from_millis(1000),
2702            initial_delay_ms: 1000,
2703            noop: false,
2704            delete: false,
2705            move_to: None,
2706            file_name: Some("foo\0bar".into()),
2707            include: None,
2708            exclude: None,
2709            ant_include: None,
2710            ant_exclude: None,
2711            include_ext: None,
2712            exclude_ext: None,
2713            recursive: false,
2714            file_exist: FileExistStrategy::Override,
2715            read_lock_strategy: ReadLockStrategy::None,
2716            idempotent_key: IdempotentKey::None,
2717            done_file_name: None,
2718            charset: None,
2719            temp_prefix: None,
2720            durable: false,
2721            auto_create: true,
2722            starting_directory_must_exist: false,
2723            read_timeout: Duration::from_millis(30_000),
2724            read_timeout_ms: 30_000,
2725            write_timeout: Duration::from_millis(30_000),
2726            write_timeout_ms: 30_000,
2727            max_depth: usize::MAX,
2728            min_depth: 0,
2729            max_messages_per_poll: 0,
2730            eager_max_messages_per_poll: true,
2731            shuffle: false,
2732            sort_spec: None,
2733        };
2734        let result = config.validate();
2735        assert!(result.is_err(), "should reject null byte in filename");
2736        let err = result.unwrap_err().to_string();
2737        assert!(
2738            err.contains("null"),
2739            "error should mention null bytes: {err}"
2740        );
2741    }
2742
2743    #[test]
2744    fn test_rejects_empty_filename() {
2745        let config = FileConfig {
2746            directory: "/tmp/inbox".into(),
2747            delay: Duration::from_millis(500),
2748            delay_ms: 500,
2749            initial_delay: Duration::from_millis(1000),
2750            initial_delay_ms: 1000,
2751            noop: false,
2752            delete: false,
2753            move_to: None,
2754            file_name: Some("".into()),
2755            include: None,
2756            exclude: None,
2757            ant_include: None,
2758            ant_exclude: None,
2759            include_ext: None,
2760            exclude_ext: None,
2761            recursive: false,
2762            file_exist: FileExistStrategy::Override,
2763            read_lock_strategy: ReadLockStrategy::None,
2764            idempotent_key: IdempotentKey::None,
2765            done_file_name: None,
2766            charset: None,
2767            temp_prefix: None,
2768            durable: false,
2769            auto_create: true,
2770            starting_directory_must_exist: false,
2771            read_timeout: Duration::from_millis(30_000),
2772            read_timeout_ms: 30_000,
2773            write_timeout: Duration::from_millis(30_000),
2774            write_timeout_ms: 30_000,
2775            max_depth: usize::MAX,
2776            min_depth: 0,
2777            max_messages_per_poll: 0,
2778            eager_max_messages_per_poll: true,
2779            shuffle: false,
2780            sort_spec: None,
2781        };
2782        let result = config.validate();
2783        assert!(result.is_err(), "should reject empty filename");
2784        let err = result.unwrap_err().to_string();
2785        assert!(
2786            err.contains("empty") || err.contains("must not"),
2787            "error should mention empty: {err}"
2788        );
2789    }
2790
2791    #[test]
2792    fn test_rejects_nonexistent_directory_when_starting_directory_must_exist() {
2793        let result =
2794            FileConfig::from_uri("file:/tmp/nonexistent_dir_12345?startingDirectoryMustExist=true");
2795        assert!(
2796            result.is_err(),
2797            "should reject non-existent directory when startingDirectoryMustExist=true"
2798        );
2799        let err = result.unwrap_err().to_string();
2800        assert!(
2801            err.contains("does not exist"),
2802            "error should mention directory does not exist: {err}"
2803        );
2804    }
2805
2806    #[test]
2807    fn test_accepts_existing_directory_when_starting_directory_must_exist() {
2808        let dir = tempfile::tempdir().unwrap();
2809        let dir_path = dir.path().to_str().unwrap();
2810        let result =
2811            FileConfig::from_uri(&format!("file:{dir_path}?startingDirectoryMustExist=true"));
2812        assert!(
2813            result.is_ok(),
2814            "should accept existing directory when startingDirectoryMustExist=true: {:?}",
2815            result.err()
2816        );
2817    }
2818
2819    #[test]
2820    fn test_valid_config_passes() {
2821        let cfg = FileConfig::from_uri("file:/tmp/inbox").unwrap();
2822        assert!(cfg.validate().is_ok());
2823    }
2824
2825    #[test]
2826    fn test_file_exist_strategy_rejects_unknown() {
2827        // Unknown strategy values should be rejected by from_str
2828        let result = FileExistStrategy::from_str("BogusValue");
2829        assert!(
2830            result.is_err(),
2831            "unknown FileExistStrategy should return Err"
2832        );
2833    }
2834
2835    #[test]
2836    fn test_path_contains_traversal_detects_parent_dir() {
2837        assert!(path_contains_traversal("../etc/passwd"));
2838        assert!(path_contains_traversal("foo/../bar"));
2839        assert!(path_contains_traversal(".."));
2840        assert!(path_contains_traversal("a/b/../../../c"));
2841    }
2842
2843    #[test]
2844    fn test_path_contains_traversal_accepts_safe_paths() {
2845        assert!(!path_contains_traversal("safe/path"));
2846        assert!(!path_contains_traversal("/absolute/path"));
2847        assert!(!path_contains_traversal("filename.txt"));
2848        assert!(!path_contains_traversal(".hidden"));
2849        assert!(!path_contains_traversal(""));
2850    }
2851
2852    // -----------------------------------------------------------------------
2853    // File modification detection tests (C-20)
2854    // -----------------------------------------------------------------------
2855
2856    #[tokio::test]
2857    async fn test_stream_detects_file_modified_during_read() {
2858        use futures::StreamExt;
2859
2860        let dir = tempfile::tempdir().unwrap();
2861        let file_path = dir.path().join("mutable.txt");
2862        std::fs::write(&file_path, b"initial content here").unwrap();
2863
2864        // Capture initial metadata
2865        let initial_meta = std::fs::metadata(&file_path).unwrap();
2866        let initial_size = initial_meta.len();
2867        let initial_mtime = initial_meta.modified().ok();
2868
2869        // Create a raw file stream (simulating what the consumer does)
2870        let file = tokio::fs::File::open(&file_path).await.unwrap();
2871        let raw_stream = ReaderStream::new(file).map(|res| res.map_err(CamelError::from));
2872
2873        let mut stream = ModificationDetectingStream::new(
2874            raw_stream,
2875            file_path.clone(),
2876            initial_size,
2877            initial_mtime,
2878        );
2879
2880        // Read a chunk (file not modified yet — should succeed)
2881        let chunk = stream.next().await;
2882        assert!(chunk.is_some(), "should produce at least one chunk");
2883        assert!(chunk.as_ref().unwrap().is_ok(), "first chunk should be Ok");
2884
2885        // Modify the file (change content/size)
2886        std::fs::write(&file_path, b"modified content - different size!!").unwrap();
2887
2888        // Drain remaining chunks — the stream should produce an error after EOF
2889        let mut got_error = false;
2890        while let Some(result) = stream.next().await {
2891            if let Err(err) = result {
2892                let msg = err.to_string();
2893                assert!(
2894                    msg.contains("file modified during read"),
2895                    "expected modification error, got: {msg}"
2896                );
2897                got_error = true;
2898                break;
2899            }
2900        }
2901
2902        assert!(
2903            got_error,
2904            "stream should detect file was modified during read"
2905        );
2906    }
2907
2908    #[tokio::test]
2909    async fn test_stream_succeeds_when_file_not_modified() {
2910        use futures::StreamExt;
2911
2912        let dir = tempfile::tempdir().unwrap();
2913        let file_path = dir.path().join("stable.txt");
2914        std::fs::write(&file_path, b"stable content").unwrap();
2915
2916        let initial_meta = std::fs::metadata(&file_path).unwrap();
2917        let initial_size = initial_meta.len();
2918        let initial_mtime = initial_meta.modified().ok();
2919
2920        let file = tokio::fs::File::open(&file_path).await.unwrap();
2921        let raw_stream = ReaderStream::new(file).map(|res| res.map_err(CamelError::from));
2922
2923        let mut stream = ModificationDetectingStream::new(
2924            raw_stream,
2925            file_path.clone(),
2926            initial_size,
2927            initial_mtime,
2928        );
2929
2930        // Drain entire stream — should produce no errors
2931        let mut all_ok = true;
2932        while let Some(result) = stream.next().await {
2933            if result.is_err() {
2934                all_ok = false;
2935                break;
2936            }
2937        }
2938
2939        assert!(
2940            all_ok,
2941            "stream should complete without errors when file is not modified"
2942        );
2943    }
2944
2945    // -----------------------------------------------------------------------
2946    // SortSpec::from_str tests
2947    // -----------------------------------------------------------------------
2948
2949    #[test]
2950    fn test_sort_spec_bare_field() {
2951        let spec: SortSpec = "file:name".parse().unwrap();
2952        assert_eq!(spec.groups.len(), 1);
2953        let g = &spec.groups[0];
2954        assert_eq!(g.field, SortField::Name);
2955        assert!(!g.reverse);
2956        assert!(!g.ignore_case);
2957    }
2958
2959    #[test]
2960    fn test_sort_spec_reverse() {
2961        let spec: SortSpec = "reverse:file:length".parse().unwrap();
2962        assert_eq!(spec.groups.len(), 1);
2963        let g = &spec.groups[0];
2964        assert_eq!(g.field, SortField::Length);
2965        assert!(g.reverse);
2966        assert!(!g.ignore_case);
2967    }
2968
2969    #[test]
2970    fn test_sort_spec_ignore_case() {
2971        let spec: SortSpec = "ignoreCase:file:name".parse().unwrap();
2972        assert_eq!(spec.groups.len(), 1);
2973        let g = &spec.groups[0];
2974        assert_eq!(g.field, SortField::Name);
2975        assert!(!g.reverse);
2976        assert!(g.ignore_case);
2977    }
2978
2979    #[test]
2980    fn test_sort_spec_reverse_ignore_case() {
2981        let spec: SortSpec = "reverse:ignoreCase:file:modified".parse().unwrap();
2982        assert_eq!(spec.groups.len(), 1);
2983        let g = &spec.groups[0];
2984        assert_eq!(g.field, SortField::Modified);
2985        assert!(g.reverse);
2986        assert!(g.ignore_case);
2987    }
2988
2989    #[test]
2990    fn test_sort_spec_wrong_order_rejection() {
2991        let result: Result<SortSpec, _> = "ignoreCase:reverse:file:name".parse();
2992        assert!(result.is_err());
2993        let err = result.unwrap_err();
2994        assert!(
2995            err.contains("reverse must precede"),
2996            "expected 'reverse must precede' error, got: {err}"
2997        );
2998    }
2999
3000    #[test]
3001    fn test_sort_spec_multi_group() {
3002        let spec: SortSpec = "file:name;reverse:file:length".parse().unwrap();
3003        assert_eq!(spec.groups.len(), 2);
3004        assert_eq!(spec.groups[0].field, SortField::Name);
3005        assert!(!spec.groups[0].reverse);
3006        assert_eq!(spec.groups[1].field, SortField::Length);
3007        assert!(spec.groups[1].reverse);
3008    }
3009
3010    #[test]
3011    fn test_sort_spec_unsupported_field() {
3012        let result: Result<SortSpec, _> = "file:unknown".parse();
3013        assert!(result.is_err());
3014        let err = result.unwrap_err();
3015        assert!(
3016            err.contains("unsupported"),
3017            "expected 'unsupported' error, got: {err}"
3018        );
3019    }
3020
3021    #[test]
3022    fn test_sort_spec_empty_string() {
3023        let result: Result<SortSpec, _> = "".parse();
3024        assert!(result.is_err());
3025        let err = result.unwrap_err();
3026        assert!(
3027            err.contains("at least one group"),
3028            "expected 'at least one group' error, got: {err}"
3029        );
3030    }
3031
3032    #[tokio::test]
3033    async fn test_max_depth_limits_recursion() {
3034        let dir = tempfile::tempdir().unwrap();
3035        let base = dir.path();
3036        fs::create_dir_all(base.join("sub/deep")).await.unwrap();
3037        fs::write(base.join("root.txt"), b"r").await.unwrap();
3038        fs::write(base.join("sub/mid.txt"), b"m").await.unwrap();
3039        fs::write(base.join("sub/deep/leaf.txt"), b"l")
3040            .await
3041            .unwrap();
3042
3043        let config = FileConfig::from_uri(&format!(
3044            "file://{}?recursive=true&maxDepth=2",
3045            base.display()
3046        ))
3047        .unwrap();
3048        let filters = CompiledFilters::compile(&config).unwrap();
3049        let mut seen = HashSet::new();
3050        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3051            .await
3052            .unwrap()
3053            .candidates;
3054        let names: Vec<String> = candidates
3055            .iter()
3056            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3057            .collect();
3058        assert!(names.contains(&"root.txt".to_string()));
3059        assert!(names.contains(&"mid.txt".to_string()));
3060        assert!(!names.contains(&"leaf.txt".to_string()));
3061    }
3062
3063    #[tokio::test]
3064    async fn test_min_depth_skips_base_files() {
3065        let dir = tempfile::tempdir().unwrap();
3066        let base = dir.path();
3067        fs::create_dir_all(base.join("sub")).await.unwrap();
3068        fs::write(base.join("root.txt"), b"r").await.unwrap();
3069        fs::write(base.join("sub/deep.txt"), b"d").await.unwrap();
3070
3071        let config = FileConfig::from_uri(&format!(
3072            "file://{}?recursive=true&minDepth=2",
3073            base.display()
3074        ))
3075        .unwrap();
3076        let filters = CompiledFilters::compile(&config).unwrap();
3077        let mut seen = HashSet::new();
3078        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3079            .await
3080            .unwrap()
3081            .candidates;
3082        let names: Vec<String> = candidates
3083            .iter()
3084            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3085            .collect();
3086        assert!(!names.contains(&"root.txt".to_string()));
3087        assert!(names.contains(&"deep.txt".to_string()));
3088    }
3089
3090    #[tokio::test]
3091    async fn test_include_ext_filter() {
3092        let dir = tempfile::tempdir().unwrap();
3093        let base = dir.path();
3094        fs::write(base.join("data.txt"), b"t").await.unwrap();
3095        fs::write(base.join("data.csv"), b"c").await.unwrap();
3096        fs::write(base.join("data.json"), b"j").await.unwrap();
3097
3098        let config =
3099            FileConfig::from_uri(&format!("file://{}?includeExt=txt,csv", base.display())).unwrap();
3100        let filters = CompiledFilters::compile(&config).unwrap();
3101        let mut seen = HashSet::new();
3102        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3103            .await
3104            .unwrap()
3105            .candidates;
3106        let names: Vec<String> = candidates
3107            .iter()
3108            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3109            .collect();
3110        assert_eq!(names.len(), 2);
3111        assert!(names.contains(&"data.txt".to_string()));
3112        assert!(names.contains(&"data.csv".to_string()));
3113    }
3114
3115    #[tokio::test]
3116    async fn test_ant_include_glob() {
3117        let dir = tempfile::tempdir().unwrap();
3118        let base = dir.path();
3119        fs::write(base.join("report.txt"), b"r").await.unwrap();
3120        fs::write(base.join("data.csv"), b"d").await.unwrap();
3121        fs::write(base.join("image.png"), b"i").await.unwrap();
3122
3123        let config =
3124            FileConfig::from_uri(&format!("file://{}?antInclude=*.txt,*.csv", base.display()))
3125                .unwrap();
3126        let filters = CompiledFilters::compile(&config).unwrap();
3127        let mut seen = HashSet::new();
3128        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3129            .await
3130            .unwrap()
3131            .candidates;
3132        let names: Vec<String> = candidates
3133            .iter()
3134            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3135            .collect();
3136        assert_eq!(names.len(), 2);
3137        assert!(names.contains(&"report.txt".to_string()));
3138        assert!(names.contains(&"data.csv".to_string()));
3139    }
3140
3141    #[tokio::test]
3142    async fn test_exclude_ext_filter() {
3143        let dir = tempfile::tempdir().unwrap();
3144        let base = dir.path();
3145        fs::write(base.join("keep.txt"), b"k").await.unwrap();
3146        fs::write(base.join("skip.log"), b"s").await.unwrap();
3147
3148        let config =
3149            FileConfig::from_uri(&format!("file://{}?excludeExt=log", base.display())).unwrap();
3150        let filters = CompiledFilters::compile(&config).unwrap();
3151        let mut seen = HashSet::new();
3152        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3153            .await
3154            .unwrap()
3155            .candidates;
3156        let names: Vec<String> = candidates
3157            .iter()
3158            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3159            .collect();
3160        assert_eq!(names.len(), 1);
3161        assert!(names.contains(&"keep.txt".to_string()));
3162    }
3163
3164    #[tokio::test]
3165    async fn test_ant_exclude_glob() {
3166        let dir = tempfile::tempdir().unwrap();
3167        let base = dir.path();
3168        fs::write(base.join("report.txt"), b"r").await.unwrap();
3169        fs::write(base.join("temp.tmp"), b"t").await.unwrap();
3170
3171        let config =
3172            FileConfig::from_uri(&format!("file://{}?antExclude=*.tmp", base.display())).unwrap();
3173        let filters = CompiledFilters::compile(&config).unwrap();
3174        let mut seen = HashSet::new();
3175        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3176            .await
3177            .unwrap()
3178            .candidates;
3179        let names: Vec<String> = candidates
3180            .iter()
3181            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3182            .collect();
3183        assert_eq!(names.len(), 1);
3184        assert!(names.contains(&"report.txt".to_string()));
3185    }
3186
3187    #[tokio::test]
3188    async fn test_done_file_consumer_skips_without_marker() {
3189        let dir = tempfile::tempdir().unwrap();
3190        let base = dir.path();
3191        fs::write(base.join("ready.txt"), b"ready").await.unwrap();
3192        fs::write(base.join("ready.txt.done"), b"").await.unwrap();
3193        fs::write(base.join("pending.txt"), b"pending")
3194            .await
3195            .unwrap();
3196
3197        let config = FileConfig::from_uri(&format!(
3198            "file://{}?doneFileName=${{file:name}}.done",
3199            base.display()
3200        ))
3201        .unwrap();
3202        let filters = CompiledFilters::compile(&config).unwrap();
3203        let mut seen = HashSet::new();
3204        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3205            .await
3206            .unwrap()
3207            .candidates;
3208        let names: Vec<String> = candidates
3209            .iter()
3210            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3211            .collect();
3212        assert!(names.contains(&"ready.txt".to_string()));
3213        assert!(!names.contains(&"pending.txt".to_string()));
3214        assert!(!names.contains(&"ready.txt.done".to_string()));
3215    }
3216
3217    #[tokio::test]
3218    async fn test_done_file_static_pattern() {
3219        let dir = tempfile::tempdir().unwrap();
3220        let base = dir.path();
3221        fs::write(base.join("data.txt"), b"d").await.unwrap();
3222        fs::write(base.join("ready"), b"").await.unwrap();
3223
3224        let config =
3225            FileConfig::from_uri(&format!("file://{}?doneFileName=ready", base.display())).unwrap();
3226        let filters = CompiledFilters::compile(&config).unwrap();
3227        let mut seen = HashSet::new();
3228        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3229            .await
3230            .unwrap()
3231            .candidates;
3232        assert_eq!(candidates.len(), 1);
3233        assert_eq!(
3234            candidates[0].path.file_name().unwrap().to_str().unwrap(),
3235            "data.txt"
3236        );
3237    }
3238
3239    // -----------------------------------------------------------------------
3240    // Sort + shuffle tests
3241    // -----------------------------------------------------------------------
3242
3243    #[tokio::test]
3244    async fn test_sort_by_name() {
3245        let dir = tempfile::tempdir().unwrap();
3246        let base = dir.path();
3247        fs::write(base.join("c.txt"), b"3").await.unwrap();
3248        fs::write(base.join("a.txt"), b"1").await.unwrap();
3249        fs::write(base.join("b.txt"), b"2").await.unwrap();
3250
3251        let config =
3252            FileConfig::from_uri(&format!("file://{}?sortBy=file:name", base.display())).unwrap();
3253        let filters = CompiledFilters::compile(&config).unwrap();
3254        let mut seen = HashSet::new();
3255        let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3256            .await
3257            .unwrap()
3258            .candidates;
3259        apply_sort_and_limit(&mut candidates, &config);
3260        let names: Vec<&str> = candidates
3261            .iter()
3262            .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3263            .collect();
3264        assert_eq!(names, vec!["a.txt", "b.txt", "c.txt"]);
3265    }
3266
3267    #[tokio::test]
3268    async fn test_sort_by_length() {
3269        let dir = tempfile::tempdir().unwrap();
3270        let base = dir.path();
3271        fs::write(base.join("big.txt"), b"xxx").await.unwrap();
3272        fs::write(base.join("small.txt"), b"x").await.unwrap();
3273
3274        let config =
3275            FileConfig::from_uri(&format!("file://{}?sortBy=file:length", base.display())).unwrap();
3276        let filters = CompiledFilters::compile(&config).unwrap();
3277        let mut seen = HashSet::new();
3278        let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3279            .await
3280            .unwrap()
3281            .candidates;
3282        apply_sort_and_limit(&mut candidates, &config);
3283        let names: Vec<&str> = candidates
3284            .iter()
3285            .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3286            .collect();
3287        assert_eq!(names, vec!["small.txt", "big.txt"]);
3288    }
3289
3290    #[tokio::test]
3291    async fn test_sort_by_reverse_name() {
3292        let dir = tempfile::tempdir().unwrap();
3293        let base = dir.path();
3294        fs::write(base.join("a.txt"), b"1").await.unwrap();
3295        fs::write(base.join("b.txt"), b"2").await.unwrap();
3296        fs::write(base.join("c.txt"), b"3").await.unwrap();
3297
3298        let config = FileConfig::from_uri(&format!(
3299            "file://{}?sortBy=reverse:file:name",
3300            base.display()
3301        ))
3302        .unwrap();
3303        let filters = CompiledFilters::compile(&config).unwrap();
3304        let mut seen = HashSet::new();
3305        let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3306            .await
3307            .unwrap()
3308            .candidates;
3309        apply_sort_and_limit(&mut candidates, &config);
3310        let names: Vec<&str> = candidates
3311            .iter()
3312            .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3313            .collect();
3314        assert_eq!(names, vec!["c.txt", "b.txt", "a.txt"]);
3315    }
3316
3317    #[tokio::test]
3318    async fn test_shuffle_deterministic() {
3319        let dir = tempfile::tempdir().unwrap();
3320        let base = dir.path();
3321        fs::write(base.join("a.txt"), b"1").await.unwrap();
3322        fs::write(base.join("b.txt"), b"2").await.unwrap();
3323        fs::write(base.join("c.txt"), b"3").await.unwrap();
3324        fs::write(base.join("d.txt"), b"4").await.unwrap();
3325
3326        let config =
3327            FileConfig::from_uri(&format!("file://{}?shuffle=true", base.display())).unwrap();
3328        let filters = CompiledFilters::compile(&config).unwrap();
3329        let mut seen = HashSet::new();
3330        let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3331            .await
3332            .unwrap()
3333            .candidates;
3334        crate::poll_logic::apply_sort_and_limit(&mut candidates, &config);
3335
3336        let mut candidates2 =
3337            crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3338                .await
3339                .unwrap()
3340                .candidates;
3341        crate::poll_logic::apply_sort_and_limit(&mut candidates2, &config);
3342
3343        let names1: Vec<&str> = candidates
3344            .iter()
3345            .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3346            .collect();
3347        let names2: Vec<&str> = candidates2
3348            .iter()
3349            .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3350            .collect();
3351        assert_eq!(names1, names2);
3352        assert_ne!(names1, vec!["a.txt", "b.txt", "c.txt", "d.txt"]);
3353    }
3354
3355    #[tokio::test]
3356    async fn test_non_eager_limit() {
3357        let dir = tempfile::tempdir().unwrap();
3358        let base = dir.path();
3359        fs::write(base.join("a.txt"), b"1").await.unwrap();
3360        fs::write(base.join("b.txt"), b"2").await.unwrap();
3361        fs::write(base.join("c.txt"), b"3").await.unwrap();
3362
3363        let config = FileConfig::from_uri(&format!(
3364            "file://{}?maxMessagesPerPoll=2&eagerMaxMessagesPerPoll=false",
3365            base.display()
3366        ))
3367        .unwrap();
3368        let filters = CompiledFilters::compile(&config).unwrap();
3369        let mut seen = HashSet::new();
3370        let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3371            .await
3372            .unwrap()
3373            .candidates;
3374        assert_eq!(candidates.len(), 3);
3375        crate::poll_logic::apply_sort_and_limit(&mut candidates, &config);
3376        assert_eq!(candidates.len(), 2);
3377    }
3378
3379    #[tokio::test]
3380    async fn test_poll_one_file_sets_all_headers() {
3381        let dir = tempfile::tempdir().unwrap();
3382        let base = dir.path();
3383        fs::write(base.join("test.txt"), b"hello world")
3384            .await
3385            .unwrap();
3386
3387        let config = FileConfig::from_uri(&format!("file:{}?noop=true", base.display())).unwrap();
3388
3389        let file_path = base.join("test.txt");
3390        let include_re = None;
3391        let exclude_re = None;
3392        let mut seen = std::collections::HashSet::new();
3393        let in_process_locks = std::sync::Arc::new(DashMap::new());
3394        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
3395
3396        let result = poll_one_file(
3397            &config,
3398            file_path.clone(),
3399            base,
3400            &include_re,
3401            &exclude_re,
3402            &mut seen,
3403            &in_process_locks,
3404            &idempotent_repo,
3405        )
3406        .await
3407        .unwrap();
3408
3409        let exchange = result.expect("poll_one_file should return an exchange");
3410
3411        let camel_file_name = exchange
3412            .input
3413            .header("CamelFileName")
3414            .and_then(|v| v.as_str().map(String::from))
3415            .expect("CamelFileName header");
3416        let camel_file_name_only = exchange
3417            .input
3418            .header("CamelFileNameOnly")
3419            .and_then(|v| v.as_str().map(String::from))
3420            .expect("CamelFileNameOnly header");
3421        let camel_file_absolute_path = exchange
3422            .input
3423            .header("CamelFileAbsolutePath")
3424            .and_then(|v| v.as_str().map(String::from))
3425            .expect("CamelFileAbsolutePath header");
3426        exchange
3427            .input
3428            .header("CamelFileLength")
3429            .and_then(|v| v.as_u64())
3430            .expect("CamelFileLength header");
3431        exchange
3432            .input
3433            .header("CamelFileLastModified")
3434            .and_then(|v| v.as_u64())
3435            .expect("CamelFileLastModified header");
3436        let camel_file_path = exchange
3437            .input
3438            .header("CamelFilePath")
3439            .and_then(|v| v.as_str().map(String::from))
3440            .expect("CamelFilePath header");
3441        let camel_file_parent = exchange
3442            .input
3443            .header("CamelFileParent")
3444            .and_then(|v| v.as_str().map(String::from))
3445            .expect("CamelFileParent header");
3446        let camel_file_canonical_path = exchange
3447            .input
3448            .header("CamelFileCanonicalPath")
3449            .and_then(|v| v.as_str().map(String::from))
3450            .expect("CamelFileCanonicalPath header");
3451        let camel_file_relative_path = exchange
3452            .input
3453            .header("CamelFileRelativePath")
3454            .and_then(|v| v.as_str().map(String::from))
3455            .expect("CamelFileRelativePath header");
3456
3457        assert_eq!(camel_file_name, "test.txt");
3458        assert_eq!(camel_file_name_only, "test.txt");
3459        assert_eq!(camel_file_relative_path, camel_file_name);
3460        assert_eq!(camel_file_path, base.to_string_lossy());
3461        assert_eq!(camel_file_parent, base.to_string_lossy());
3462        assert!(camel_file_absolute_path.ends_with("test.txt"));
3463        assert!(camel_file_canonical_path.ends_with("test.txt"));
3464    }
3465
3466    #[test]
3467    fn test_try_rename_requires_temp_prefix() {
3468        let result = FileConfig::from_uri("file:///tmp?fileExist=TryRename");
3469        assert!(result.is_err());
3470        assert!(
3471            result
3472                .unwrap_err()
3473                .to_string()
3474                .contains("TryRename requires tempPrefix")
3475        );
3476    }
3477
3478    #[test]
3479    fn test_try_rename_with_temp_prefix_ok() {
3480        let result = FileConfig::from_uri("file:///tmp?fileExist=TryRename&tempPrefix=tmp_");
3481        assert!(result.is_ok());
3482        assert_eq!(result.unwrap().file_exist, FileExistStrategy::TryRename);
3483    }
3484
3485    #[tokio::test]
3486    async fn test_try_rename_producer_writes_file() {
3487        use tower::ServiceExt;
3488
3489        let dir = tempfile::tempdir().unwrap();
3490        let base = dir.path();
3491
3492        let component = FileComponent::new();
3493        let ctx = NoOpComponentContext;
3494        let endpoint = component
3495            .create_endpoint(
3496                &format!(
3497                    "file://{}?fileExist=TryRename&tempPrefix=tmp_",
3498                    base.display()
3499                ),
3500                &ctx,
3501            )
3502            .unwrap();
3503        let ctx = test_producer_ctx();
3504        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3505
3506        let mut exchange = Exchange::new(Message::new("hello"));
3507        exchange.input.set_header(
3508            "CamelFileName",
3509            serde_json::Value::String("output.txt".to_string()),
3510        );
3511
3512        producer.oneshot(exchange).await.unwrap();
3513
3514        let target = base.join("output.txt");
3515        assert!(target.exists(), "output file should exist");
3516        let content = std::fs::read_to_string(&target).unwrap();
3517        assert_eq!(content, "hello");
3518
3519        let tmp_files: Vec<_> = std::fs::read_dir(base)
3520            .unwrap()
3521            .filter_map(|e| e.ok())
3522            .filter(|e| e.file_name().to_string_lossy().starts_with("tmp_"))
3523            .collect();
3524        assert!(tmp_files.is_empty(), "no temp files should remain");
3525    }
3526
3527    // -----------------------------------------------------------------------
3528    // Integration tests: scan_candidates + apply_sort_and_limit pipeline
3529    // -----------------------------------------------------------------------
3530
3531    #[tokio::test]
3532    async fn test_pipeline_sort_then_limit() {
3533        let dir = tempfile::tempdir().unwrap();
3534        let base = dir.path();
3535        fs::write(base.join("c.txt"), b"ccc").await.unwrap();
3536        fs::write(base.join("a.txt"), b"a").await.unwrap();
3537        fs::write(base.join("b.txt"), b"bb").await.unwrap();
3538
3539        let config = FileConfig::from_uri(&format!(
3540            "file://{}?sortBy=file:length&maxMessagesPerPoll=2&eagerMaxMessagesPerPoll=false",
3541            base.display()
3542        ))
3543        .unwrap();
3544        let filters = CompiledFilters::compile(&config).unwrap();
3545        let mut seen = HashSet::new();
3546        let mut candidates = scan_candidates(&config, &filters, base, &mut seen)
3547            .await
3548            .unwrap()
3549            .candidates;
3550        apply_sort_and_limit(&mut candidates, &config);
3551        assert_eq!(candidates.len(), 2);
3552        assert_eq!(
3553            candidates[0].path.file_name().unwrap().to_str().unwrap(),
3554            "a.txt"
3555        );
3556        assert_eq!(
3557            candidates[1].path.file_name().unwrap().to_str().unwrap(),
3558            "b.txt"
3559        );
3560    }
3561
3562    #[tokio::test]
3563    async fn test_sort_reverse_ignore_case() {
3564        let dir = tempfile::tempdir().unwrap();
3565        let base = dir.path();
3566        fs::write(base.join("Alpha.txt"), b"a").await.unwrap();
3567        fs::write(base.join("beta.txt"), b"b").await.unwrap();
3568
3569        let config = FileConfig::from_uri(&format!(
3570            "file://{}?sortBy=reverse:ignoreCase:file:name",
3571            base.display()
3572        ))
3573        .unwrap();
3574        let filters = CompiledFilters::compile(&config).unwrap();
3575        let mut seen = HashSet::new();
3576        let mut candidates = scan_candidates(&config, &filters, base, &mut seen)
3577            .await
3578            .unwrap()
3579            .candidates;
3580        apply_sort_and_limit(&mut candidates, &config);
3581        assert_eq!(
3582            candidates[0].path.file_name().unwrap().to_str().unwrap(),
3583            "beta.txt"
3584        );
3585    }
3586
3587    #[tokio::test]
3588    async fn test_done_file_noop_no_deletion() {
3589        let dir = tempfile::tempdir().unwrap();
3590        let base = dir.path();
3591        fs::write(base.join("data.txt"), b"d").await.unwrap();
3592        fs::write(base.join("done"), b"").await.unwrap();
3593
3594        let config = FileConfig::from_uri(&format!(
3595            "file://{}?doneFileName=done&noop=true",
3596            base.display()
3597        ))
3598        .unwrap();
3599        let filters = CompiledFilters::compile(&config).unwrap();
3600        let mut seen = HashSet::new();
3601        let candidates = scan_candidates(&config, &filters, base, &mut seen)
3602            .await
3603            .unwrap()
3604            .candidates;
3605        assert_eq!(candidates.len(), 1);
3606        assert!(base.join("done").exists());
3607    }
3608
3609    #[tokio::test]
3610    async fn test_static_done_file_deleted_after_all_processed() {
3611        let dir = tempfile::tempdir().unwrap();
3612        let base = dir.path();
3613        fs::write(base.join("data.txt"), b"d").await.unwrap();
3614        fs::write(base.join("ready"), b"").await.unwrap();
3615
3616        let config = FileConfig::from_uri(&format!(
3617            "file://{}?doneFileName=ready&delete=true",
3618            base.display()
3619        ))
3620        .unwrap();
3621        let filters = CompiledFilters::compile(&config).unwrap();
3622        let mut seen = HashSet::new();
3623        let in_process_locks = std::sync::Arc::new(DashMap::new());
3624        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
3625        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
3626        let token = CancellationToken::new();
3627        let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
3628
3629        poll_directory(
3630            &config,
3631            &ctx,
3632            &filters,
3633            &mut seen,
3634            &in_process_locks,
3635            &idempotent_repo,
3636        )
3637        .await
3638        .unwrap();
3639
3640        assert!(rx.try_recv().is_ok());
3641        assert!(!base.join("ready").exists());
3642    }
3643
3644    #[tokio::test]
3645    async fn test_static_done_file_not_deleted_when_limited() {
3646        let dir = tempfile::tempdir().unwrap();
3647        let base = dir.path();
3648        fs::write(base.join("a.txt"), b"a").await.unwrap();
3649        fs::write(base.join("b.txt"), b"b").await.unwrap();
3650        fs::write(base.join("ready"), b"").await.unwrap();
3651
3652        let config = FileConfig::from_uri(&format!(
3653            "file://{}?doneFileName=ready&delete=true&maxMessagesPerPoll=1&eagerMaxMessagesPerPoll=true",
3654            base.display()
3655        ))
3656        .unwrap();
3657        let filters = CompiledFilters::compile(&config).unwrap();
3658        let mut seen = HashSet::new();
3659        let in_process_locks = std::sync::Arc::new(DashMap::new());
3660        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
3661        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
3662        let token = CancellationToken::new();
3663        let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
3664
3665        poll_directory(
3666            &config,
3667            &ctx,
3668            &filters,
3669            &mut seen,
3670            &in_process_locks,
3671            &idempotent_repo,
3672        )
3673        .await
3674        .unwrap();
3675
3676        let _ = rx.try_recv();
3677        assert!(base.join("ready").exists());
3678    }
3679
3680    #[tokio::test]
3681    async fn test_dynamic_done_file_deleted_per_file() {
3682        let dir = tempfile::tempdir().unwrap();
3683        let base = dir.path();
3684        fs::write(base.join("data.txt"), b"d").await.unwrap();
3685        fs::write(base.join("data.txt.done"), b"").await.unwrap();
3686
3687        let config = FileConfig::from_uri(&format!(
3688            "file://{}?doneFileName=${{file:name}}.done&delete=true",
3689            base.display()
3690        ))
3691        .unwrap();
3692        let filters = CompiledFilters::compile(&config).unwrap();
3693        let mut seen = HashSet::new();
3694        let in_process_locks = std::sync::Arc::new(DashMap::new());
3695        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
3696        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
3697        let token = CancellationToken::new();
3698        let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
3699
3700        poll_directory(
3701            &config,
3702            &ctx,
3703            &filters,
3704            &mut seen,
3705            &in_process_locks,
3706            &idempotent_repo,
3707        )
3708        .await
3709        .unwrap();
3710
3711        assert!(rx.try_recv().is_ok());
3712        assert!(!base.join("data.txt.done").exists());
3713    }
3714}