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