Skip to main content

camel_component_file/
lib.rs

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