Skip to main content

camel_component_file/
lib.rs

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