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 the base exists, enforce strict containment: canonicalize the base,
1206    // reject any symlinked component below the ORIGINAL (non-canonicalized)
1207    // base, and require the nearest existing ancestor of the target to stay
1208    // within the canonicalized base.
1209    if base_dir.exists() {
1210        let canonical_base = base_dir.canonicalize().map_err(|e| {
1211            CamelError::ProcessorError(format!("Cannot canonicalize base directory: {}", e))
1212        })?;
1213
1214        // Relative components against the original (non-canonicalized) base.
1215        let rel = target_path.strip_prefix(base_dir).map_err(|_| {
1216            CamelError::ProcessorError(format!(
1217                "Path '{}' is not under base '{}'",
1218                target_path.display(),
1219                base_dir.display()
1220            ))
1221        })?;
1222        if path_contains_traversal(&rel.to_string_lossy()) {
1223            return Err(CamelError::ProcessorError(format!(
1224                "Path '{}' contains directory traversal",
1225                target_path.display()
1226            )));
1227        }
1228
1229        // Symlink-chain rejection: every cumulative component below the base
1230        // must be symlink-free at validation time, including symlinks that
1231        // resolve inside the canonicalized base (they are mutable
1232        // retargeting points). The base itself may be a symlink
1233        // (canonicalized above, per the operator contract).
1234        let mut cumulative = base_dir.to_path_buf();
1235        for component in rel.components() {
1236            cumulative.push(component);
1237            if std::fs::symlink_metadata(&cumulative).is_ok_and(|m| m.file_type().is_symlink()) {
1238                return Err(CamelError::ProcessorError(format!(
1239                    "Path '{}' traverses symlinked component '{}' below base '{}'",
1240                    target_path.display(),
1241                    cumulative.display(),
1242                    base_dir.display()
1243                )));
1244            }
1245        }
1246
1247        // Nearest-existing-ancestor containment: walk up to the closest
1248        // existing ancestor, canonicalize it, and require it to stay within
1249        // the canonicalized base. Since the base exists and lexically
1250        // prefixes the target, the walk terminates at the base at the
1251        // latest; the parent-less fallback degenerates to base containment.
1252        let mut ancestor = target_path;
1253        while std::fs::symlink_metadata(ancestor).is_err() {
1254            match ancestor.parent() {
1255                Some(parent) => ancestor = parent,
1256                None => {
1257                    ancestor = base_dir;
1258                    break;
1259                }
1260            }
1261        }
1262        let canonical_ancestor = ancestor.canonicalize().map_err(|e| {
1263            CamelError::ProcessorError(format!(
1264                "Cannot canonicalize existing ancestor '{}': {}",
1265                ancestor.display(),
1266                e
1267            ))
1268        })?;
1269        if !canonical_ancestor.starts_with(&canonical_base) {
1270            return Err(CamelError::ProcessorError(format!(
1271                "Path '{}' is outside base directory '{}'",
1272                canonical_ancestor.display(),
1273                canonical_base.display()
1274            )));
1275        }
1276    } else {
1277        // Base dir doesn't exist yet (auto_create case): nothing below it
1278        // can be a pre-existing symlink, so a lexical traversal check
1279        // suffices.
1280        let rel = target_path.strip_prefix(base_dir).map_err(|_| {
1281            CamelError::ProcessorError(format!(
1282                "Path '{}' is not under base '{}'",
1283                target_path.display(),
1284                base_dir.display()
1285            ))
1286        })?;
1287        let rel_str = rel.to_string_lossy();
1288        if path_contains_traversal(&rel_str) {
1289            return Err(CamelError::ProcessorError(format!(
1290                "Path '{}' contains directory traversal",
1291                target_path.display()
1292            )));
1293        }
1294    }
1295
1296    Ok(())
1297}
1298
1299// ---------------------------------------------------------------------------
1300// FileProducer
1301// ---------------------------------------------------------------------------
1302
1303#[derive(Clone)]
1304struct FileProducer {
1305    config: FileConfig,
1306}
1307
1308impl FileProducer {
1309    async fn resolve_filename(
1310        exchange: &Exchange,
1311        config: &FileConfig,
1312    ) -> Result<String, CamelError> {
1313        let raw = if let Some(name) = exchange
1314            .input
1315            .header("CamelFileName")
1316            .and_then(|v| v.as_str())
1317        {
1318            Some(name.to_string())
1319        } else {
1320            config.file_name.clone()
1321        };
1322
1323        match raw {
1324            Some(name) if name.contains("${") => {
1325                let lang = SimpleLanguage::new();
1326                let expr = lang.create_expression(&name).map_err(|e| {
1327                    CamelError::ProcessorError(format!(
1328                        "cannot parse fileName expression '{}': {e}",
1329                        name
1330                    ))
1331                })?;
1332                let val = expr.evaluate(exchange).await.map_err(|e| {
1333                    CamelError::ProcessorError(format!(
1334                        "cannot evaluate fileName expression '{}': {e}",
1335                        name
1336                    ))
1337                })?;
1338                match val {
1339                    serde_json::Value::String(s) => Ok(s),
1340                    other => Ok(other.to_string()),
1341                }
1342            }
1343            Some(name) => Ok(name),
1344            None => Err(CamelError::ProcessorError(
1345                "No filename specified: set CamelFileName header or fileName option".to_string(),
1346            )),
1347        }
1348    }
1349}
1350
1351impl Service<Exchange> for FileProducer {
1352    type Response = Exchange;
1353    type Error = CamelError;
1354    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
1355
1356    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
1357        Poll::Ready(Ok(()))
1358    }
1359
1360    fn call(&mut self, mut exchange: Exchange) -> Self::Future {
1361        let config = self.config.clone();
1362
1363        Box::pin(async move {
1364            let file_name = FileProducer::resolve_filename(&exchange, &config).await?;
1365            let body = exchange.input.body.clone();
1366
1367            // 0. Security: lexical pre-check of the resolved name (rejects
1368            // absolute paths that would discard `dir_path` on join, NUL bytes,
1369            // and `..` traversal before any filesystem touch).
1370            validate_relative_filename(&file_name, "fileName")?;
1371
1372            let dir_path = std::path::Path::new(&config.directory);
1373            let target_path = dir_path.join(&file_name);
1374
1375            // 1. Security: validate path is within base directory
1376            validate_path_is_within_base(dir_path, &target_path)?;
1377
1378            // 2. Auto-create directories (after path validation)
1379            if config.auto_create
1380                && let Some(parent) = target_path.parent()
1381            {
1382                tokio::time::timeout(config.write_timeout, fs::create_dir_all(parent))
1383                    .await
1384                    .map_err(|_| CamelError::ProcessorError("Timeout creating directories".into()))?
1385                    .map_err(CamelError::from)?;
1386            }
1387
1388            // 2b. Post-create re-verification: a symlink followed during
1389            // directory creation yields an outside-base canonical parent and
1390            // the write is refused before any open/rename. It cannot undo
1391            // directories created during a concurrent race — documented
1392            // residual (see CONTEXT.md).
1393            validate_path_is_within_base(dir_path, &target_path)?;
1394
1395            // 3. Handle file-exist strategy
1396            match config.file_exist {
1397                FileExistStrategy::Fail => {
1398                    let mut file = tokio::time::timeout(
1399                        config.write_timeout,
1400                        open_options_no_follow()
1401                            .write(true)
1402                            .create_new(true)
1403                            .open(&target_path),
1404                    )
1405                    .await
1406                    .map_err(|_| {
1407                        CamelError::ProcessorError("Timeout opening file with create_new".into())
1408                    })?
1409                    .map_err(CamelError::from)?;
1410
1411                    write_body_with_charset(
1412                        body,
1413                        &config.charset,
1414                        &mut file,
1415                        config.write_timeout,
1416                        config.max_write_bytes,
1417                    )
1418                    .await?;
1419                    file.flush().await.map_err(CamelError::from)?;
1420                }
1421                FileExistStrategy::Ignore if target_path.exists() => return Ok(exchange),
1422                FileExistStrategy::Append => {
1423                    // Append: write directly without temp file (append is inherently non-atomic).
1424                    // O_NOFOLLOW (via open_options_no_follow) closes the dangling-symlink escape:
1425                    // a symlink leaf fails the open instead of being followed outside the base.
1426                    let mut file = tokio::time::timeout(
1427                        config.write_timeout,
1428                        open_options_no_follow()
1429                            .append(true)
1430                            .create(true)
1431                            .open(&target_path),
1432                    )
1433                    .await
1434                    .map_err(|_| {
1435                        CamelError::ProcessorError("Timeout opening file for append".into())
1436                    })?
1437                    .map_err(CamelError::from)?;
1438
1439                    write_body_with_charset(
1440                        body,
1441                        &config.charset,
1442                        &mut file,
1443                        config.write_timeout,
1444                        config.max_write_bytes,
1445                    )
1446                    .await?;
1447
1448                    file.flush().await.map_err(CamelError::from)?;
1449                }
1450                FileExistStrategy::TryRename => {
1451                    // TryRename requires an explicit tempPrefix (validated at config time).
1452                    // Delegates to atomic_write so both branches share one rename path.
1453                    let prefix = config.temp_prefix.as_deref().ok_or_else(|| {
1454                        CamelError::Config("fileExist=TryRename requires tempPrefix".into())
1455                    })?;
1456                    crate::atomic_write::atomic_write(
1457                        &target_path,
1458                        body,
1459                        Some(prefix),
1460                        config.durable,
1461                        config.write_timeout,
1462                        &config.charset,
1463                        config.max_write_bytes,
1464                    )
1465                    .await?;
1466                }
1467                _ => {
1468                    // Override (and Fail when file doesn't exist): always atomic via temp file.
1469                    // Delegates to atomic_write — fixes Bug C (rc-o6o.3): temp path was previously
1470                    // computed by joining dir_path with prefix+full_file_name, producing a path
1471                    // whose parent did not exist for nested fileName values.
1472                    crate::atomic_write::atomic_write(
1473                        &target_path,
1474                        body,
1475                        config.temp_prefix.as_deref(),
1476                        config.durable,
1477                        config.write_timeout,
1478                        &config.charset,
1479                        config.max_write_bytes,
1480                    )
1481                    .await?;
1482                }
1483            }
1484
1485            if let Some(done_pattern) = &config.done_file_name {
1486                let done_name = done_pattern.replace("${file:name}", &file_name);
1487                // Security: the substituted name derives from the (header-influenced)
1488                // file_name, so it needs the same confinement as the body write:
1489                // reject absolute paths / traversal lexically, then confirm the
1490                // joined path stays within the base directory.
1491                validate_relative_filename(&done_name, "doneFileName")?;
1492                let done_path = dir_path.join(&done_name);
1493                validate_path_is_within_base(dir_path, &done_path)?;
1494                tokio::time::timeout(
1495                    config.write_timeout,
1496                    open_options_no_follow()
1497                        .write(true)
1498                        .create(true)
1499                        .truncate(true)
1500                        .open(&done_path),
1501                )
1502                .await
1503                .map_err(|_| CamelError::ProcessorError("Timeout creating done file".into()))?
1504                .map_err(CamelError::from)?;
1505            }
1506
1507            // 4. Set output header
1508            let abs_path = target_path
1509                .canonicalize()
1510                .unwrap_or_else(|_| target_path.clone())
1511                .to_string_lossy()
1512                .to_string();
1513            exchange
1514                .input
1515                .set_header("CamelFileNameProduced", serde_json::Value::String(abs_path));
1516
1517            debug!(
1518                file = %target_path.display(),
1519                correlation_id = %exchange.correlation_id(),
1520                "File written"
1521            );
1522
1523            Ok(exchange)
1524        })
1525    }
1526}
1527
1528pub(crate) async fn write_body_with_charset(
1529    body: Body,
1530    charset: &Option<String>,
1531    file: &mut fs::File,
1532    timeout: Duration,
1533    max_bytes: u64,
1534) -> Result<(), CamelError> {
1535    match body {
1536        Body::Text(text) | Body::Xml(text) => {
1537            let bytes = encode_text_by_charset(&text, charset)?;
1538            if max_bytes > 0 && bytes.len() as u64 > max_bytes {
1539                return Err(CamelError::ProcessorError(format!(
1540                    "body of {} bytes exceeds maxWriteBytes limit of {max_bytes}",
1541                    bytes.len()
1542                )));
1543            }
1544            tokio::time::timeout(timeout, file.write_all(&bytes))
1545                .await
1546                .map_err(|_| CamelError::ProcessorError("Timeout writing file".into()))?
1547                .map_err(CamelError::from)?;
1548            Ok(())
1549        }
1550        other => {
1551            let mut reader = other.into_async_read()?;
1552            // F4-3: cap streamed bytes so a large/unbounded stream body cannot
1553            // fill the disk. `take` truncates silently, so we compare the
1554            // copied count against the cap afterwards and error.
1555            let mut limited = tokio::io::AsyncReadExt::take(
1556                &mut reader,
1557                if max_bytes > 0 {
1558                    max_bytes + 1
1559                } else {
1560                    u64::MAX
1561                },
1562            );
1563            let copied = tokio::time::timeout(timeout, io::copy(&mut limited, file))
1564                .await
1565                .map_err(|_| CamelError::ProcessorError("Timeout writing to file".into()))?
1566                .map_err(|e| CamelError::ProcessorError(e.to_string()))?;
1567            if max_bytes > 0 && copied > max_bytes {
1568                return Err(CamelError::ProcessorError(format!(
1569                    "streamed body exceeds maxWriteBytes limit of {max_bytes} bytes"
1570                )));
1571            }
1572            Ok(())
1573        }
1574    }
1575}
1576
1577fn encode_text_by_charset(text: &str, charset: &Option<String>) -> Result<Vec<u8>, CamelError> {
1578    let Some(charset) = charset.as_ref() else {
1579        return Ok(text.as_bytes().to_vec());
1580    };
1581
1582    if charset.eq_ignore_ascii_case("utf-8") {
1583        return Ok(text.as_bytes().to_vec());
1584    }
1585
1586    if charset.eq_ignore_ascii_case("iso-8859-1") {
1587        let mut out = Vec::with_capacity(text.len());
1588        for ch in text.chars() {
1589            let code = ch as u32;
1590            if code > 0xFF {
1591                return Err(CamelError::ProcessorError(format!(
1592                    "character '{ch}' cannot be encoded as ISO-8859-1"
1593                )));
1594            }
1595            out.push(code as u8);
1596        }
1597        return Ok(out);
1598    }
1599
1600    Err(CamelError::Config(format!(
1601        "unsupported charset '{charset}', supported: UTF-8, ISO-8859-1"
1602    )))
1603}
1604
1605#[cfg(test)]
1606mod tests {
1607    use camel_component_api::test_support::PanicRuntimeObservability;
1608    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
1609        std::sync::Arc::new(PanicRuntimeObservability)
1610    }
1611
1612    use super::*;
1613    use crate::poll_logic::{
1614        ModificationDetectingStream, apply_sort_and_limit, list_files, poll_one_file,
1615        scan_candidates,
1616    };
1617    use bytes::Bytes;
1618    use camel_component_api::{Message, NoOpComponentContext, StreamBody, StreamMetadata};
1619    use futures::StreamExt;
1620    use std::time::Duration;
1621    use tokio_util::io::ReaderStream;
1622    use tokio_util::sync::CancellationToken;
1623
1624    fn test_producer_ctx() -> ProducerContext {
1625        ProducerContext::new()
1626    }
1627
1628    #[test]
1629    fn test_file_config_defaults() {
1630        let config = FileConfig::from_uri("file:/tmp/inbox").unwrap();
1631        assert_eq!(config.directory, "/tmp/inbox");
1632        assert_eq!(config.delay, Duration::from_millis(500));
1633        assert_eq!(config.initial_delay, Duration::from_millis(1000));
1634        assert!(!config.noop);
1635        assert!(!config.delete);
1636        assert_eq!(config.move_to, Some(".camel".to_string()));
1637        assert!(config.file_name.is_none());
1638        assert!(config.include.is_none());
1639        assert!(config.exclude.is_none());
1640        assert!(!config.recursive);
1641        assert_eq!(config.file_exist, FileExistStrategy::Override);
1642        assert!(config.temp_prefix.is_none());
1643        assert!(config.auto_create);
1644        // New timeout defaults
1645        assert_eq!(config.read_timeout, Duration::from_secs(30));
1646        assert_eq!(config.write_timeout, Duration::from_secs(30));
1647    }
1648
1649    #[test]
1650    fn file_config_durable_defaults_to_false() {
1651        let config = FileConfig::from_uri("file:/tmp/inbox").unwrap();
1652        assert!(!config.durable, "durable must default to false");
1653    }
1654
1655    #[test]
1656    fn file_config_durable_true_parsed_from_uri() {
1657        let config = FileConfig::from_uri("file:/tmp/inbox?durable=true").unwrap();
1658        assert!(config.durable, "durable=true must parse from URI");
1659    }
1660
1661    #[test]
1662    fn file_config_durable_rejects_invalid_value() {
1663        let result = FileConfig::from_uri("file:/tmp/inbox?durable=maybe");
1664        assert!(result.is_err(), "invalid durable value must be rejected");
1665    }
1666
1667    #[test]
1668    fn test_file_config_consumer_options() {
1669        let config = FileConfig::from_uri(
1670            "file:/data/input?delay=1000&initialDelay=2000&noop=true&recursive=true&include=.*\\.csv"
1671        ).unwrap();
1672        assert_eq!(config.directory, "/data/input");
1673        assert_eq!(config.delay, Duration::from_millis(1000));
1674        assert_eq!(config.initial_delay, Duration::from_millis(2000));
1675        assert!(config.noop);
1676        assert!(config.recursive);
1677        assert_eq!(config.include, Some(".*\\.csv".to_string()));
1678    }
1679
1680    #[test]
1681    fn test_file_config_producer_options() {
1682        let config = FileConfig::from_uri(
1683            "file:/data/output?fileExist=Append&tempPrefix=.tmp&autoCreate=false&fileName=out.txt",
1684        )
1685        .unwrap();
1686        assert_eq!(config.file_exist, FileExistStrategy::Append);
1687        assert_eq!(config.temp_prefix, Some(".tmp".to_string()));
1688        assert!(!config.auto_create);
1689        assert_eq!(config.file_name, Some("out.txt".to_string()));
1690    }
1691
1692    #[test]
1693    fn test_file_config_delete_mode() {
1694        let config = FileConfig::from_uri("file:/tmp/inbox?delete=true").unwrap();
1695        assert!(config.delete);
1696        assert!(config.move_to.is_none());
1697    }
1698
1699    #[test]
1700    fn test_file_config_noop_mode() {
1701        let config = FileConfig::from_uri("file:/tmp/inbox?noop=true").unwrap();
1702        assert!(config.noop);
1703        assert!(config.move_to.is_none());
1704    }
1705
1706    #[test]
1707    fn test_file_config_wrong_scheme() {
1708        let result = FileConfig::from_uri("timer:tick");
1709        assert!(result.is_err());
1710    }
1711
1712    #[test]
1713    fn test_min_depth_greater_than_max_depth_rejected() {
1714        let result = FileConfig::from_uri("file:///tmp?minDepth=5&maxDepth=2");
1715        assert!(result.is_err());
1716        assert!(
1717            result
1718                .unwrap_err()
1719                .to_string()
1720                .contains("minDepth cannot be greater")
1721        );
1722    }
1723
1724    #[test]
1725    fn test_file_component_scheme() {
1726        let component = FileComponent::new();
1727        assert_eq!(component.scheme(), "file");
1728    }
1729
1730    #[test]
1731    fn test_file_component_creates_endpoint() {
1732        let component = FileComponent::new();
1733        let ctx = NoOpComponentContext;
1734        let endpoint = component.create_endpoint("file:/tmp/test", &ctx);
1735        assert!(endpoint.is_ok());
1736    }
1737
1738    // -----------------------------------------------------------------------
1739    // Consumer tests
1740    // -----------------------------------------------------------------------
1741
1742    #[tokio::test]
1743    async fn test_file_consumer_reads_files() {
1744        let dir = tempfile::tempdir().unwrap();
1745        let dir_path = dir.path().to_str().unwrap();
1746
1747        std::fs::write(dir.path().join("test1.txt"), "hello").unwrap();
1748        std::fs::write(dir.path().join("test2.txt"), "world").unwrap();
1749
1750        let component = FileComponent::new();
1751        let ctx = NoOpComponentContext;
1752        let endpoint = component
1753            .create_endpoint(
1754                &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100"),
1755                &ctx,
1756            )
1757            .unwrap();
1758        let mut consumer = endpoint.create_consumer(rt()).unwrap();
1759
1760        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1761        let token = CancellationToken::new();
1762        let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
1763
1764        tokio::spawn(async move {
1765            consumer.start(ctx).await.unwrap();
1766        });
1767
1768        let mut received = Vec::new();
1769        let timeout = tokio::time::timeout(Duration::from_secs(2), async {
1770            while let Some(envelope) = rx.recv().await {
1771                received.push(envelope.exchange);
1772                if received.len() == 2 {
1773                    break;
1774                }
1775            }
1776        })
1777        .await;
1778        token.cancel();
1779
1780        assert!(timeout.is_ok(), "Should have received 2 exchanges");
1781        assert_eq!(received.len(), 2);
1782
1783        for ex in &received {
1784            assert!(ex.input.header("CamelFileName").is_some());
1785            assert!(ex.input.header("CamelFileNameOnly").is_some());
1786            assert!(ex.input.header("CamelFileAbsolutePath").is_some());
1787            assert!(ex.input.header("CamelFileLength").is_some());
1788            assert!(ex.input.header("CamelFileLastModified").is_some());
1789        }
1790    }
1791
1792    #[tokio::test]
1793    async fn noop_second_poll_does_not_re_emit_seen_files() {
1794        let dir = tempfile::tempdir().unwrap();
1795        let file_path = dir.path().join("test.txt");
1796        tokio::fs::write(&file_path, b"hello").await.unwrap();
1797
1798        let uri = format!(
1799            "file:{}?noop=true&initialDelay=0&delay=50",
1800            dir.path().display()
1801        );
1802        let config = FileConfig::from_uri(&uri).unwrap();
1803        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1804        let token = CancellationToken::new();
1805        let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
1806
1807        let filters = CompiledFilters::default();
1808        let mut seen = std::collections::HashSet::new();
1809        let in_process_locks = std::sync::Arc::new(DashMap::new());
1810        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
1811
1812        poll_directory(
1813            &config,
1814            &ctx,
1815            &rt(),
1816            &filters,
1817            &mut seen,
1818            &in_process_locks,
1819            &idempotent_repo,
1820        )
1821        .await
1822        .unwrap();
1823        assert!(rx.try_recv().is_ok(), "first poll should emit file");
1824        assert!(rx.try_recv().is_err(), "should only emit once");
1825
1826        poll_directory(
1827            &config,
1828            &ctx,
1829            &rt(),
1830            &filters,
1831            &mut seen,
1832            &in_process_locks,
1833            &idempotent_repo,
1834        )
1835        .await
1836        .unwrap();
1837        assert!(
1838            rx.try_recv().is_err(),
1839            "second poll should not re-emit seen file"
1840        );
1841    }
1842
1843    #[tokio::test]
1844    async fn noop_new_files_picked_up_after_first_poll() {
1845        let dir = tempfile::tempdir().unwrap();
1846        let file1 = dir.path().join("a.txt");
1847        tokio::fs::write(&file1, b"a").await.unwrap();
1848
1849        let uri = format!(
1850            "file:{}?noop=true&initialDelay=0&delay=50",
1851            dir.path().display()
1852        );
1853        let config = FileConfig::from_uri(&uri).unwrap();
1854        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1855        let token = CancellationToken::new();
1856        let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
1857
1858        let filters = CompiledFilters::default();
1859        let mut seen = std::collections::HashSet::new();
1860        let in_process_locks = std::sync::Arc::new(DashMap::new());
1861        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
1862
1863        poll_directory(
1864            &config,
1865            &ctx,
1866            &rt(),
1867            &filters,
1868            &mut seen,
1869            &in_process_locks,
1870            &idempotent_repo,
1871        )
1872        .await
1873        .unwrap();
1874        let _ = rx.try_recv();
1875
1876        let file2 = dir.path().join("b.txt");
1877        tokio::fs::write(&file2, b"b").await.unwrap();
1878
1879        poll_directory(
1880            &config,
1881            &ctx,
1882            &rt(),
1883            &filters,
1884            &mut seen,
1885            &in_process_locks,
1886            &idempotent_repo,
1887        )
1888        .await
1889        .unwrap();
1890        assert!(
1891            rx.try_recv().is_ok(),
1892            "b.txt should be emitted on second poll"
1893        );
1894        assert!(rx.try_recv().is_err(), "a.txt should not be re-emitted");
1895    }
1896
1897    #[tokio::test]
1898    async fn test_file_consumer_include_filter() {
1899        let dir = tempfile::tempdir().unwrap();
1900        let dir_path = dir.path().to_str().unwrap();
1901
1902        std::fs::write(dir.path().join("data.csv"), "a,b,c").unwrap();
1903        std::fs::write(dir.path().join("readme.txt"), "hello").unwrap();
1904
1905        let component = FileComponent::new();
1906        let ctx = NoOpComponentContext;
1907        let endpoint = component
1908            .create_endpoint(
1909                &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&include=.*\\.csv"),
1910                &ctx,
1911            )
1912            .unwrap();
1913        let mut consumer = endpoint.create_consumer(rt()).unwrap();
1914
1915        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1916        let token = CancellationToken::new();
1917        let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
1918
1919        tokio::spawn(async move {
1920            consumer.start(ctx).await.unwrap();
1921        });
1922
1923        let mut received = Vec::new();
1924        let _ = tokio::time::timeout(Duration::from_millis(500), async {
1925            while let Some(envelope) = rx.recv().await {
1926                received.push(envelope.exchange);
1927                if received.len() == 1 {
1928                    break;
1929                }
1930            }
1931        })
1932        .await;
1933        token.cancel();
1934
1935        assert_eq!(received.len(), 1);
1936        let name = received[0]
1937            .input
1938            .header("CamelFileNameOnly")
1939            .and_then(|v| v.as_str())
1940            .unwrap();
1941        assert_eq!(name, "data.csv");
1942    }
1943
1944    #[tokio::test]
1945    async fn test_file_consumer_delete_mode() {
1946        let dir = tempfile::tempdir().unwrap();
1947        let dir_path = dir.path().to_str().unwrap();
1948
1949        std::fs::write(dir.path().join("deleteme.txt"), "bye").unwrap();
1950
1951        let component = FileComponent::new();
1952        let ctx = NoOpComponentContext;
1953        let endpoint = component
1954            .create_endpoint(
1955                &format!("file:{dir_path}?delete=true&initialDelay=0&delay=100"),
1956                &ctx,
1957            )
1958            .unwrap();
1959        let mut consumer = endpoint.create_consumer(rt()).unwrap();
1960
1961        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1962        let token = CancellationToken::new();
1963        let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
1964
1965        tokio::spawn(async move {
1966            consumer.start(ctx).await.unwrap();
1967        });
1968
1969        let _ = tokio::time::timeout(Duration::from_millis(500), async { rx.recv().await }).await;
1970        token.cancel();
1971
1972        tokio::time::sleep(Duration::from_millis(100)).await;
1973
1974        assert!(
1975            !dir.path().join("deleteme.txt").exists(),
1976            "File should be deleted"
1977        );
1978    }
1979
1980    #[tokio::test]
1981    async fn test_file_consumer_move_mode() {
1982        let dir = tempfile::tempdir().unwrap();
1983        tokio::fs::write(dir.path().join("moveme.txt"), b"data")
1984            .await
1985            .unwrap();
1986
1987        let uri = format!("file:{}?initialDelay=0&delay=50", dir.path().display());
1988        let config = FileConfig::from_uri(&uri).unwrap();
1989        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1990        let token = CancellationToken::new();
1991        let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
1992
1993        let filters = CompiledFilters::default();
1994        let mut seen = std::collections::HashSet::new();
1995        let in_process_locks = std::sync::Arc::new(DashMap::new());
1996        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
1997
1998        poll_directory(
1999            &config,
2000            &ctx,
2001            &rt(),
2002            &filters,
2003            &mut seen,
2004            &in_process_locks,
2005            &idempotent_repo,
2006        )
2007        .await
2008        .unwrap();
2009
2010        let ex = rx.try_recv().expect("should receive exchange");
2011        drop(ex);
2012
2013        assert!(
2014            !dir.path().join("moveme.txt").exists(),
2015            "Original file should be gone"
2016        );
2017        assert!(
2018            dir.path().join(".camel").join("moveme.txt").exists(),
2019            "File should be in .camel/"
2020        );
2021    }
2022
2023    #[tokio::test]
2024    async fn test_file_consumer_respects_cancellation() {
2025        let dir = tempfile::tempdir().unwrap();
2026        let dir_path = dir.path().to_str().unwrap();
2027
2028        let component = FileComponent::new();
2029        let ctx = NoOpComponentContext;
2030        let endpoint = component
2031            .create_endpoint(&format!("file:{dir_path}?initialDelay=0&delay=50"), &ctx)
2032            .unwrap();
2033        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2034
2035        let (tx, _rx) = tokio::sync::mpsc::channel(16);
2036        let token = CancellationToken::new();
2037        let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
2038
2039        let handle = tokio::spawn(async move {
2040            consumer.start(ctx).await.unwrap();
2041        });
2042
2043        tokio::time::sleep(Duration::from_millis(150)).await;
2044        token.cancel();
2045
2046        let result = tokio::time::timeout(Duration::from_secs(1), handle).await;
2047        assert!(
2048            result.is_ok(),
2049            "Consumer should have stopped after cancellation"
2050        );
2051    }
2052
2053    // -----------------------------------------------------------------------
2054    // Producer tests
2055    // -----------------------------------------------------------------------
2056
2057    #[tokio::test]
2058    async fn test_file_producer_writes_file() {
2059        use tower::ServiceExt;
2060
2061        let dir = tempfile::tempdir().unwrap();
2062        let dir_path = dir.path().to_str().unwrap();
2063
2064        let component = FileComponent::new();
2065        let ctx = NoOpComponentContext;
2066        let endpoint = component
2067            .create_endpoint(&format!("file:{dir_path}"), &ctx)
2068            .unwrap();
2069        let ctx = test_producer_ctx();
2070        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2071
2072        let mut exchange = Exchange::new(Message::new("file content"));
2073        exchange.input.set_header(
2074            "CamelFileName",
2075            serde_json::Value::String("output.txt".to_string()),
2076        );
2077
2078        let result = producer.oneshot(exchange).await.unwrap();
2079
2080        let content = std::fs::read_to_string(dir.path().join("output.txt")).unwrap();
2081        assert_eq!(content, "file content");
2082
2083        assert!(result.input.header("CamelFileNameProduced").is_some());
2084    }
2085
2086    #[tokio::test]
2087    async fn test_file_producer_auto_create_dirs() {
2088        use tower::ServiceExt;
2089
2090        let dir = tempfile::tempdir().unwrap();
2091        let dir_path = dir.path().to_str().unwrap();
2092
2093        let component = FileComponent::new();
2094        let ctx = NoOpComponentContext;
2095        let endpoint = component
2096            .create_endpoint(&format!("file:{dir_path}/sub/dir"), &ctx)
2097            .unwrap();
2098        let ctx = test_producer_ctx();
2099        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2100
2101        let mut exchange = Exchange::new(Message::new("nested"));
2102        exchange.input.set_header(
2103            "CamelFileName",
2104            serde_json::Value::String("file.txt".to_string()),
2105        );
2106
2107        producer.oneshot(exchange).await.unwrap();
2108
2109        assert!(dir.path().join("sub/dir/file.txt").exists());
2110    }
2111
2112    #[tokio::test]
2113    async fn test_file_producer_file_exist_fail() {
2114        use tower::ServiceExt;
2115
2116        let dir = tempfile::tempdir().unwrap();
2117        let dir_path = dir.path().to_str().unwrap();
2118
2119        std::fs::write(dir.path().join("existing.txt"), "old").unwrap();
2120
2121        let component = FileComponent::new();
2122        let ctx = NoOpComponentContext;
2123        let endpoint = component
2124            .create_endpoint(&format!("file:{dir_path}?fileExist=Fail"), &ctx)
2125            .unwrap();
2126        let ctx = test_producer_ctx();
2127        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2128
2129        let mut exchange = Exchange::new(Message::new("new"));
2130        exchange.input.set_header(
2131            "CamelFileName",
2132            serde_json::Value::String("existing.txt".to_string()),
2133        );
2134
2135        let result = producer.oneshot(exchange).await;
2136        assert!(
2137            result.is_err(),
2138            "Should fail when file exists with Fail strategy"
2139        );
2140    }
2141
2142    #[tokio::test]
2143    async fn test_file_producer_file_exist_append() {
2144        use tower::ServiceExt;
2145
2146        let dir = tempfile::tempdir().unwrap();
2147        let dir_path = dir.path().to_str().unwrap();
2148
2149        std::fs::write(dir.path().join("append.txt"), "old").unwrap();
2150
2151        let component = FileComponent::new();
2152        let ctx = NoOpComponentContext;
2153        let endpoint = component
2154            .create_endpoint(&format!("file:{dir_path}?fileExist=Append"), &ctx)
2155            .unwrap();
2156        let ctx = test_producer_ctx();
2157        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2158
2159        let mut exchange = Exchange::new(Message::new("new"));
2160        exchange.input.set_header(
2161            "CamelFileName",
2162            serde_json::Value::String("append.txt".to_string()),
2163        );
2164
2165        producer.oneshot(exchange).await.unwrap();
2166
2167        let content = std::fs::read_to_string(dir.path().join("append.txt")).unwrap();
2168        assert_eq!(content, "oldnew");
2169    }
2170
2171    #[tokio::test]
2172    async fn test_file_producer_temp_prefix() {
2173        use tower::ServiceExt;
2174
2175        let dir = tempfile::tempdir().unwrap();
2176        let dir_path = dir.path().to_str().unwrap();
2177
2178        let component = FileComponent::new();
2179        let ctx = NoOpComponentContext;
2180        let endpoint = component
2181            .create_endpoint(&format!("file:{dir_path}?tempPrefix=.tmp"), &ctx)
2182            .unwrap();
2183        let ctx = test_producer_ctx();
2184        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2185
2186        let mut exchange = Exchange::new(Message::new("atomic write"));
2187        exchange.input.set_header(
2188            "CamelFileName",
2189            serde_json::Value::String("final.txt".to_string()),
2190        );
2191
2192        producer.oneshot(exchange).await.unwrap();
2193
2194        assert!(dir.path().join("final.txt").exists());
2195        assert!(!dir.path().join(".tmpfinal.txt").exists());
2196        let content = std::fs::read_to_string(dir.path().join("final.txt")).unwrap();
2197        assert_eq!(content, "atomic write");
2198    }
2199
2200    #[tokio::test]
2201    async fn file_producer_writes_nested_filename_override() {
2202        use tower::ServiceExt;
2203
2204        let dir = tempfile::tempdir().unwrap();
2205        let dir_path = dir.path().to_str().unwrap();
2206
2207        let component = FileComponent::new();
2208        let ctx = NoOpComponentContext;
2209        // Override is the default fileExist strategy.
2210        let endpoint = component
2211            .create_endpoint(&format!("file:{dir_path}"), &ctx)
2212            .unwrap();
2213        let ctx = test_producer_ctx();
2214        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2215
2216        let mut exchange = Exchange::new(Message::new("nested body"));
2217        exchange.input.set_header(
2218            "CamelFileName",
2219            serde_json::Value::String("sub/dir/payload.bin".to_string()),
2220        );
2221
2222        producer.oneshot(exchange).await.unwrap();
2223
2224        let target = dir.path().join("sub").join("dir").join("payload.bin");
2225        assert!(target.exists(), "target file should exist at {:?}", target);
2226        let content = std::fs::read(&target).unwrap();
2227        assert_eq!(content, b"nested body");
2228        // No leftover temp file in the directory root (Bug C symptom: .tmp.sub/dir/payload.bin
2229        // path was being computed and the parent dir did not exist).
2230        assert!(
2231            !dir.path().join(".tmp.sub").exists(),
2232            "no stray temp path should be created at directory root"
2233        );
2234    }
2235
2236    #[tokio::test]
2237    async fn file_producer_writes_nested_filename_try_rename() {
2238        use tower::ServiceExt;
2239
2240        let dir = tempfile::tempdir().unwrap();
2241        let dir_path = dir.path().to_str().unwrap();
2242
2243        let component = FileComponent::new();
2244        let ctx = NoOpComponentContext;
2245        let endpoint = component
2246            .create_endpoint(
2247                &format!("file:{dir_path}?fileExist=TryRename&tempPrefix=.t."),
2248                &ctx,
2249            )
2250            .unwrap();
2251        let ctx = test_producer_ctx();
2252        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2253
2254        let mut exchange = Exchange::new(Message::new("try-rename nested"));
2255        exchange.input.set_header(
2256            "CamelFileName",
2257            serde_json::Value::String("deep/dir/x.bin".to_string()),
2258        );
2259
2260        producer.oneshot(exchange).await.unwrap();
2261
2262        let target = dir.path().join("deep").join("dir").join("x.bin");
2263        assert!(target.exists());
2264        assert_eq!(std::fs::read(&target).unwrap(), b"try-rename nested");
2265        // Temp file must not leak.
2266        assert!(
2267            !dir.path()
2268                .join("deep")
2269                .join("dir")
2270                .join(".t.x.bin")
2271                .exists(),
2272            "temp file should have been renamed away"
2273        );
2274    }
2275
2276    #[tokio::test]
2277    async fn file_producer_writes_nested_filename_fail_strategy() {
2278        use tower::ServiceExt;
2279
2280        let dir = tempfile::tempdir().unwrap();
2281        let dir_path = dir.path().to_str().unwrap();
2282
2283        let component = FileComponent::new();
2284        let ctx = NoOpComponentContext;
2285        let endpoint = component
2286            .create_endpoint(&format!("file:{dir_path}?fileExist=Fail"), &ctx)
2287            .unwrap();
2288        let ctx = test_producer_ctx();
2289        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2290
2291        let mut exchange = Exchange::new(Message::new("fail-strategy nested"));
2292        exchange.input.set_header(
2293            "CamelFileName",
2294            serde_json::Value::String("a/b/c.bin".to_string()),
2295        );
2296
2297        producer.oneshot(exchange).await.unwrap();
2298
2299        let target = dir.path().join("a").join("b").join("c.bin");
2300        assert!(target.exists());
2301        assert_eq!(std::fs::read(&target).unwrap(), b"fail-strategy nested");
2302    }
2303
2304    #[tokio::test]
2305    async fn test_file_producer_uses_filename_option() {
2306        use tower::ServiceExt;
2307
2308        let dir = tempfile::tempdir().unwrap();
2309        let dir_path = dir.path().to_str().unwrap();
2310
2311        let component = FileComponent::new();
2312        let ctx = NoOpComponentContext;
2313        let endpoint = component
2314            .create_endpoint(&format!("file:{dir_path}?fileName=fixed.txt"), &ctx)
2315            .unwrap();
2316        let ctx = test_producer_ctx();
2317        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2318
2319        let exchange = Exchange::new(Message::new("content"));
2320
2321        producer.oneshot(exchange).await.unwrap();
2322        assert!(dir.path().join("fixed.txt").exists());
2323    }
2324
2325    #[tokio::test]
2326    async fn test_file_producer_no_filename_errors() {
2327        use tower::ServiceExt;
2328
2329        let dir = tempfile::tempdir().unwrap();
2330        let dir_path = dir.path().to_str().unwrap();
2331
2332        let component = FileComponent::new();
2333        let ctx = NoOpComponentContext;
2334        let endpoint = component
2335            .create_endpoint(&format!("file:{dir_path}"), &ctx)
2336            .unwrap();
2337        let ctx = test_producer_ctx();
2338        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2339
2340        let exchange = Exchange::new(Message::new("content"));
2341
2342        let result = producer.oneshot(exchange).await;
2343        assert!(result.is_err(), "Should error when no filename is provided");
2344    }
2345
2346    // -----------------------------------------------------------------------
2347    // Security tests - Path traversal protection
2348    // -----------------------------------------------------------------------
2349
2350    #[tokio::test]
2351    async fn test_file_producer_rejects_path_traversal_parent_directory() {
2352        use tower::ServiceExt;
2353
2354        let dir = tempfile::tempdir().unwrap();
2355        let dir_path = dir.path().to_str().unwrap();
2356
2357        // Create a subdirectory
2358        std::fs::create_dir(dir.path().join("subdir")).unwrap();
2359        std::fs::write(dir.path().join("secret.txt"), "secret").unwrap();
2360
2361        let component = FileComponent::new();
2362        let ctx = NoOpComponentContext;
2363        let endpoint = component
2364            .create_endpoint(&format!("file:{dir_path}/subdir"), &ctx)
2365            .unwrap();
2366        let ctx = test_producer_ctx();
2367        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2368
2369        let mut exchange = Exchange::new(Message::new("malicious"));
2370        exchange.input.set_header(
2371            "CamelFileName",
2372            serde_json::Value::String("../secret.txt".to_string()),
2373        );
2374
2375        let result = producer.oneshot(exchange).await;
2376        assert!(result.is_err(), "Should reject path traversal attempt");
2377
2378        let err = result.unwrap_err();
2379        // Rejected either by the lexical pre-check ("traversal") or by the
2380        // canonicalize-based base check ("outside") — both are valid refusals.
2381        let msg = err.to_string();
2382        assert!(
2383            msg.contains("outside") || msg.contains("traversal"),
2384            "Error should mention path confinement violation, got: {msg}"
2385        );
2386    }
2387
2388    #[tokio::test]
2389    async fn test_file_producer_rejects_absolute_path_outside_base() {
2390        use tower::ServiceExt;
2391
2392        let dir = tempfile::tempdir().unwrap();
2393        let dir_path = dir.path().to_str().unwrap();
2394
2395        let component = FileComponent::new();
2396        let ctx = NoOpComponentContext;
2397        let endpoint = component
2398            .create_endpoint(&format!("file:{dir_path}"), &ctx)
2399            .unwrap();
2400        let ctx = test_producer_ctx();
2401        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2402
2403        let mut exchange = Exchange::new(Message::new("malicious"));
2404        exchange.input.set_header(
2405            "CamelFileName",
2406            serde_json::Value::String("/etc/passwd".to_string()),
2407        );
2408
2409        let result = producer.oneshot(exchange).await;
2410        assert!(result.is_err(), "Should reject absolute path outside base");
2411    }
2412
2413    #[tokio::test]
2414    async fn test_file_producer_does_not_create_dirs_before_path_validation() {
2415        use tower::ServiceExt;
2416
2417        let dir = tempfile::tempdir().unwrap();
2418        let dir_path = dir.path().to_str().unwrap();
2419
2420        let component = FileComponent::new();
2421        let ctx = NoOpComponentContext;
2422        let endpoint = component
2423            .create_endpoint(&format!("file:{dir_path}"), &ctx)
2424            .unwrap();
2425        let ctx = test_producer_ctx();
2426        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2427
2428        let outside_parent = dir.path().parent().unwrap().join("escaped-create-dir");
2429        if outside_parent.exists() {
2430            std::fs::remove_dir_all(&outside_parent).unwrap();
2431        }
2432
2433        let mut exchange = Exchange::new(Message::new("malicious"));
2434        exchange.input.set_header(
2435            "CamelFileName",
2436            serde_json::Value::String("../escaped-create-dir/file.txt".to_string()),
2437        );
2438
2439        let result = producer.oneshot(exchange).await;
2440        assert!(result.is_err(), "Should reject traversal path");
2441        assert!(
2442            !outside_parent.exists(),
2443            "Must not create outside directories before path validation"
2444        );
2445    }
2446
2447    #[cfg(unix)]
2448    #[tokio::test]
2449    async fn test_list_files_skips_symlink_cycle() {
2450        use std::os::unix::fs as unix_fs;
2451
2452        let dir = tempfile::tempdir().unwrap();
2453        let nested = dir.path().join("nested");
2454        std::fs::create_dir_all(&nested).unwrap();
2455        std::fs::write(nested.join("a.txt"), "x").unwrap();
2456        unix_fs::symlink(dir.path(), nested.join("loop")).unwrap();
2457
2458        let files = list_files(dir.path(), true).await.unwrap();
2459        assert_eq!(files.iter().filter(|p| p.ends_with("a.txt")).count(), 1);
2460    }
2461
2462    // -----------------------------------------------------------------------
2463    // Large file streaming tests
2464    // -----------------------------------------------------------------------
2465
2466    #[tokio::test]
2467    #[ignore = "slow test: file polling (run with --ignored)"]
2468    async fn test_large_file_streaming_constant_memory() {
2469        use std::io::Write;
2470        use tempfile::NamedTempFile;
2471
2472        // Create a 150MB file (larger than 100MB limit)
2473        let mut temp_file = NamedTempFile::new().unwrap();
2474        let file_size = 150 * 1024 * 1024; // 150MB
2475        let chunk = vec![b'X'; 1024 * 1024]; // 1MB chunk
2476
2477        for _ in 0..150 {
2478            temp_file.write_all(&chunk).unwrap();
2479        }
2480        temp_file.flush().unwrap();
2481
2482        let dir = temp_file.path().parent().unwrap();
2483        let dir_path = dir.to_str().unwrap();
2484        let file_name = temp_file
2485            .path()
2486            .file_name()
2487            .unwrap()
2488            .to_str()
2489            .unwrap()
2490            .to_string();
2491
2492        // Read file as stream (should succeed with lazy evaluation)
2493        let component = FileComponent::new();
2494        let component_ctx = NoOpComponentContext;
2495        let endpoint = component
2496            .create_endpoint(
2497                &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&fileName={file_name}"),
2498                &component_ctx,
2499            )
2500            .unwrap();
2501        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2502
2503        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
2504        let token = CancellationToken::new();
2505        let ctx = ConsumerContext::new(tx, token.clone(), "file-test-route".to_string());
2506
2507        tokio::spawn(async move {
2508            let _ = consumer.start(ctx).await;
2509        });
2510
2511        let exchange = tokio::time::timeout(Duration::from_secs(5), async {
2512            rx.recv().await.unwrap().exchange
2513        })
2514        .await
2515        .expect("Should receive exchange");
2516        token.cancel();
2517
2518        // Verify body is a stream (not materialized)
2519        assert!(matches!(exchange.input.body, Body::Stream(_)));
2520
2521        // Verify we can read metadata without consuming
2522        if let Body::Stream(ref stream_body) = exchange.input.body {
2523            assert!(stream_body.metadata.size_hint.is_some());
2524            let size = stream_body.metadata.size_hint.unwrap();
2525            assert_eq!(size, file_size as u64);
2526        }
2527
2528        // Materializing should fail (exceeds 100MB limit)
2529        if let Body::Stream(stream_body) = exchange.input.body {
2530            let body = Body::Stream(stream_body);
2531            let result = body.into_bytes(100 * 1024 * 1024).await;
2532            assert!(result.is_err());
2533        }
2534
2535        // But we CAN read chunks one at a time (simulating line-by-line processing)
2536        // This demonstrates lazy evaluation - we don't need to load entire file
2537        let component2 = FileComponent::new();
2538        let endpoint2 = component2
2539            .create_endpoint(
2540                &format!("file:{dir_path}?noop=true&initialDelay=0&delay=100&fileName={file_name}"),
2541                &component_ctx,
2542            )
2543            .unwrap();
2544        let mut consumer2 = endpoint2.create_consumer(rt()).unwrap();
2545
2546        let (tx2, mut rx2) = tokio::sync::mpsc::channel(16);
2547        let token2 = CancellationToken::new();
2548        let ctx2 = ConsumerContext::new(tx2, token2.clone(), "file-test-route-2".to_string());
2549
2550        tokio::spawn(async move {
2551            let _ = consumer2.start(ctx2).await;
2552        });
2553
2554        let exchange2 = tokio::time::timeout(Duration::from_secs(5), async {
2555            rx2.recv().await.unwrap().exchange
2556        })
2557        .await
2558        .expect("Should receive exchange");
2559        token2.cancel();
2560
2561        if let Body::Stream(stream_body) = exchange2.input.body {
2562            let mut stream_lock = stream_body.stream.lock().await;
2563            let mut stream = stream_lock.take().unwrap();
2564
2565            // Read first chunk (size varies based on ReaderStream's buffer)
2566            if let Some(chunk_result) = stream.next().await {
2567                let chunk = chunk_result.unwrap();
2568                assert!(!chunk.is_empty());
2569                assert!(chunk.len() < file_size);
2570                // Memory usage is constant - we only have this chunk in memory, not 150MB
2571            }
2572        }
2573    }
2574
2575    // -----------------------------------------------------------------------
2576    // Streaming producer tests
2577    // -----------------------------------------------------------------------
2578
2579    #[tokio::test]
2580    async fn test_producer_writes_stream_body() {
2581        let dir = tempfile::tempdir().unwrap();
2582        let dir_path = dir.path().to_str().unwrap();
2583        let uri = format!("file:{dir_path}?fileName=out.txt");
2584
2585        let component = FileComponent::new();
2586        let ctx = NoOpComponentContext;
2587        let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2588        let producer = endpoint
2589            .create_producer(rt(), &test_producer_ctx())
2590            .unwrap();
2591
2592        let chunks: Vec<Result<Bytes, CamelError>> = vec![
2593            Ok(Bytes::from("hello ")),
2594            Ok(Bytes::from("streaming ")),
2595            Ok(Bytes::from("world")),
2596        ];
2597        let stream = futures::stream::iter(chunks);
2598        let body = Body::Stream(StreamBody {
2599            stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2600            metadata: StreamMetadata {
2601                size_hint: None,
2602                content_type: None,
2603                origin: None,
2604            },
2605        });
2606
2607        let exchange = Exchange::new(Message::new(body));
2608        tower::ServiceExt::oneshot(producer, exchange)
2609            .await
2610            .unwrap();
2611
2612        let content = tokio::fs::read_to_string(format!("{dir_path}/out.txt"))
2613            .await
2614            .unwrap();
2615        assert_eq!(content, "hello streaming world");
2616    }
2617
2618    #[tokio::test]
2619    async fn test_producer_stream_atomic_no_partial_on_error() {
2620        // If the stream errors mid-write, no file should exist at the target path
2621        let dir = tempfile::tempdir().unwrap();
2622        let dir_path = dir.path().to_str().unwrap();
2623        let uri = format!("file:{dir_path}?fileName=out.txt");
2624
2625        let component = FileComponent::new();
2626        let ctx = NoOpComponentContext;
2627        let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2628        let producer = endpoint
2629            .create_producer(rt(), &test_producer_ctx())
2630            .unwrap();
2631
2632        let chunks: Vec<Result<Bytes, CamelError>> = vec![
2633            Ok(Bytes::from("partial")),
2634            Err(CamelError::ProcessorError(
2635                "simulated stream error".to_string(),
2636            )),
2637        ];
2638        let stream = futures::stream::iter(chunks);
2639        let body = Body::Stream(StreamBody {
2640            stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2641            metadata: StreamMetadata {
2642                size_hint: None,
2643                content_type: None,
2644                origin: None,
2645            },
2646        });
2647
2648        let exchange = Exchange::new(Message::new(body));
2649        let result = tower::ServiceExt::oneshot(producer, exchange).await;
2650        assert!(
2651            result.is_err(),
2652            "expected error when stream fails mid-write"
2653        );
2654
2655        // Target file must NOT exist — write was aborted and temp file cleaned up
2656        assert!(
2657            !std::path::Path::new(&format!("{dir_path}/out.txt")).exists(),
2658            "partial file must not exist after failed write"
2659        );
2660
2661        // Temp file must also be cleaned up
2662        assert!(
2663            !std::path::Path::new(&format!("{dir_path}/.tmp.out.txt")).exists(),
2664            "temp file must be cleaned up after failed write"
2665        );
2666    }
2667
2668    #[tokio::test]
2669    async fn test_producer_stream_append() {
2670        let dir = tempfile::tempdir().unwrap();
2671        let dir_path = dir.path().to_str().unwrap();
2672        let target = format!("{dir_path}/out.txt");
2673
2674        // Pre-create file with initial content
2675        tokio::fs::write(&target, b"line1\n").await.unwrap();
2676
2677        let uri = format!("file:{dir_path}?fileName=out.txt&fileExist=Append");
2678        let component = FileComponent::new();
2679        let ctx = NoOpComponentContext;
2680        let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2681        let producer = endpoint
2682            .create_producer(rt(), &test_producer_ctx())
2683            .unwrap();
2684
2685        let chunks: Vec<Result<Bytes, CamelError>> = vec![Ok(Bytes::from("line2\n"))];
2686        let stream = futures::stream::iter(chunks);
2687        let body = Body::Stream(StreamBody {
2688            stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2689            metadata: StreamMetadata {
2690                size_hint: None,
2691                content_type: None,
2692                origin: None,
2693            },
2694        });
2695
2696        let exchange = Exchange::new(Message::new(body));
2697        tower::ServiceExt::oneshot(producer, exchange)
2698            .await
2699            .unwrap();
2700
2701        let content = tokio::fs::read_to_string(&target).await.unwrap();
2702        assert_eq!(content, "line1\nline2\n");
2703    }
2704
2705    #[tokio::test]
2706    async fn test_producer_stream_append_partial_on_error() {
2707        // Append is inherently non-atomic: if the stream errors mid-write,
2708        // the file will contain partial data. This test documents that behavior.
2709        let dir = tempfile::tempdir().unwrap();
2710        let dir_path = dir.path().to_str().unwrap();
2711        let target = format!("{dir_path}/out.txt");
2712
2713        // Pre-create file with initial content
2714        tokio::fs::write(&target, b"initial\n").await.unwrap();
2715
2716        let uri = format!("file:{dir_path}?fileName=out.txt&fileExist=Append");
2717        let component = FileComponent::new();
2718        let ctx = NoOpComponentContext;
2719        let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2720        let producer = endpoint
2721            .create_producer(rt(), &test_producer_ctx())
2722            .unwrap();
2723
2724        // Stream with an error in the middle
2725        let chunks: Vec<Result<Bytes, CamelError>> = vec![
2726            Ok(Bytes::from("partial-")), // This will be written
2727            Err(CamelError::ProcessorError("stream error".to_string())), // This causes failure
2728            Ok(Bytes::from("never-written")), // This won't be reached
2729        ];
2730        let stream = futures::stream::iter(chunks);
2731        let body = Body::Stream(StreamBody {
2732            stream: std::sync::Arc::new(tokio::sync::Mutex::new(Some(Box::pin(stream)))),
2733            metadata: StreamMetadata {
2734                size_hint: None,
2735                content_type: None,
2736                origin: None,
2737            },
2738        });
2739
2740        let exchange = Exchange::new(Message::new(body));
2741        let result = tower::ServiceExt::oneshot(producer, exchange).await;
2742
2743        // 1. Producer must return an error
2744        assert!(
2745            result.is_err(),
2746            "expected error when stream fails during append"
2747        );
2748
2749        // 2. File must contain initial content + partial data written before the error
2750        let content = tokio::fs::read_to_string(&target).await.unwrap();
2751        assert_eq!(
2752            content, "initial\npartial-",
2753            "append leaves partial data on stream error (non-atomic by nature)"
2754        );
2755    }
2756
2757    #[tokio::test]
2758    async fn test_producer_stream_already_consumed_errors() {
2759        let dir = tempfile::tempdir().unwrap();
2760        let dir_path = dir.path().to_str().unwrap();
2761        let uri = format!("file:{dir_path}?fileName=out.txt");
2762
2763        let component = FileComponent::new();
2764        let ctx = NoOpComponentContext;
2765        let endpoint = component.create_endpoint(&uri, &ctx).unwrap();
2766        let producer = endpoint
2767            .create_producer(rt(), &test_producer_ctx())
2768            .unwrap();
2769
2770        // Mutex holds None -> stream already consumed
2771        type MaybeStream = std::sync::Arc<
2772            tokio::sync::Mutex<
2773                Option<
2774                    std::pin::Pin<
2775                        Box<dyn futures::Stream<Item = Result<Bytes, CamelError>> + Send>,
2776                    >,
2777                >,
2778            >,
2779        >;
2780        let arc: MaybeStream = std::sync::Arc::new(tokio::sync::Mutex::new(None));
2781        let body = Body::Stream(StreamBody {
2782            stream: arc,
2783            metadata: StreamMetadata {
2784                size_hint: None,
2785                content_type: None,
2786                origin: None,
2787            },
2788        });
2789
2790        let exchange = Exchange::new(Message::new(body));
2791        let result = tower::ServiceExt::oneshot(producer, exchange).await;
2792        assert!(
2793            result.is_err(),
2794            "expected error for already-consumed stream"
2795        );
2796    }
2797
2798    // -----------------------------------------------------------------------
2799    // GlobalConfig tests - apply_global_defaults behavior
2800    // -----------------------------------------------------------------------
2801
2802    #[test]
2803    fn test_global_config_applied_to_endpoint() {
2804        // Global config with non-default values
2805        let global = FileGlobalConfig::default()
2806            .with_delay_ms(2000)
2807            .with_initial_delay_ms(5000)
2808            .with_read_timeout_ms(60_000)
2809            .with_write_timeout_ms(45_000);
2810        let component = FileComponent::with_config(global);
2811        let ctx = NoOpComponentContext;
2812        // URI uses no explicit delay/timeout params → macro defaults apply
2813        let endpoint = component.create_endpoint("file:/tmp/inbox", &ctx).unwrap();
2814        // We cannot call endpoint.config directly (FileEndpoint is private),
2815        // but we can test apply_global_defaults on FileConfig directly:
2816        let mut config = FileConfig::from_uri("file:/tmp/inbox").unwrap();
2817        let global2 = FileGlobalConfig::default()
2818            .with_delay_ms(2000)
2819            .with_initial_delay_ms(5000)
2820            .with_read_timeout_ms(60_000)
2821            .with_write_timeout_ms(45_000);
2822        config.apply_global_defaults(&global2);
2823        assert_eq!(config.delay, Duration::from_millis(2000));
2824        assert_eq!(config.initial_delay, Duration::from_millis(5000));
2825        assert_eq!(config.read_timeout, Duration::from_millis(60_000));
2826        assert_eq!(config.write_timeout, Duration::from_millis(45_000));
2827        // endpoint creation succeeds too
2828        let _ = endpoint; // just verify create_endpoint didn't fail
2829    }
2830
2831    #[test]
2832    fn test_uri_param_wins_over_global_config() {
2833        // URI explicitly sets delay=1000 (NOT the 500ms macro default)
2834        let mut config =
2835            FileConfig::from_uri("file:/tmp/inbox?delay=1000&initialDelay=2000").unwrap();
2836        // Global config would want 3000ms delay
2837        let global = FileGlobalConfig::default()
2838            .with_delay_ms(3000)
2839            .with_initial_delay_ms(4000);
2840        config.apply_global_defaults(&global);
2841        // URI value of 1000ms must be preserved (not replaced by 3000ms)
2842        assert_eq!(config.delay, Duration::from_millis(1000));
2843        // URI value of 2000ms must be preserved (not replaced by 4000ms)
2844        assert_eq!(config.initial_delay, Duration::from_millis(2000));
2845        // read_timeout was not set by URI → macro default (30000) → global wins if different
2846        // (read_timeout stays at 30000 since global has same default = 30000)
2847        assert_eq!(config.read_timeout, Duration::from_millis(30_000));
2848    }
2849
2850    #[tokio::test]
2851    async fn test_file_producer_filename_simple_language_from_header() {
2852        use tower::ServiceExt;
2853
2854        let dir = tempfile::tempdir().unwrap();
2855        let dir_path = dir.path().to_str().unwrap();
2856
2857        let component = FileComponent::new();
2858        let ctx = NoOpComponentContext;
2859        let endpoint = component
2860            .create_endpoint(&format!("file:{dir_path}"), &ctx)
2861            .unwrap();
2862        let ctx = test_producer_ctx();
2863        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2864
2865        let mut exchange = Exchange::new(Message::new("content"));
2866        exchange
2867            .input
2868            .set_header("CamelTimerCounter", serde_json::Value::Number(42.into()));
2869        exchange.input.set_header(
2870            "CamelFileName",
2871            serde_json::Value::String("test-${header.CamelTimerCounter}.txt".to_string()),
2872        );
2873
2874        producer.oneshot(exchange).await.unwrap();
2875
2876        assert!(
2877            dir.path().join("test-42.txt").exists(),
2878            "fileName should have been evaluated from Simple Language expression"
2879        );
2880        let content = std::fs::read_to_string(dir.path().join("test-42.txt")).unwrap();
2881        assert_eq!(content, "content");
2882    }
2883
2884    #[tokio::test]
2885    async fn test_file_producer_filename_simple_language_from_uri_param() {
2886        use tower::ServiceExt;
2887
2888        let dir = tempfile::tempdir().unwrap();
2889        let dir_path = dir.path().to_str().unwrap();
2890
2891        let component = FileComponent::new();
2892        let ctx = NoOpComponentContext;
2893        let endpoint = component
2894            .create_endpoint(
2895                &format!("file:{dir_path}?fileName=msg-${{header.id}}.dat"),
2896                &ctx,
2897            )
2898            .unwrap();
2899        let ctx = test_producer_ctx();
2900        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2901
2902        let mut exchange = Exchange::new(Message::new("data"));
2903        exchange
2904            .input
2905            .set_header("id", serde_json::Value::String("abc".to_string()));
2906
2907        producer.oneshot(exchange).await.unwrap();
2908
2909        assert!(
2910            dir.path().join("msg-abc.dat").exists(),
2911            "fileName URI param should have been evaluated from Simple Language expression"
2912        );
2913    }
2914
2915    #[tokio::test]
2916    async fn test_file_producer_filename_literal_without_expression() {
2917        use tower::ServiceExt;
2918
2919        let dir = tempfile::tempdir().unwrap();
2920        let dir_path = dir.path().to_str().unwrap();
2921
2922        let component = FileComponent::new();
2923        let ctx = NoOpComponentContext;
2924        let endpoint = component
2925            .create_endpoint(&format!("file:{dir_path}?fileName=plain.txt"), &ctx)
2926            .unwrap();
2927        let ctx = test_producer_ctx();
2928        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2929
2930        let exchange = Exchange::new(Message::new("data"));
2931        producer.oneshot(exchange).await.unwrap();
2932
2933        assert!(
2934            dir.path().join("plain.txt").exists(),
2935            "literal fileName without expressions should still work"
2936        );
2937    }
2938
2939    // -----------------------------------------------------------------------
2940    // Config validation tests
2941    // -----------------------------------------------------------------------
2942
2943    #[test]
2944    fn test_rejects_path_traversal_in_move_to() {
2945        let result = FileConfig::from_uri("file:/tmp/inbox?move=../etc/passwd");
2946        assert!(result.is_err(), "should reject path traversal in move_to");
2947        let err = result.unwrap_err().to_string();
2948        assert!(
2949            err.contains("path traversal"),
2950            "error should mention path traversal: {err}"
2951        );
2952    }
2953
2954    #[test]
2955    fn test_rejects_absolute_move_to() {
2956        let result = FileConfig::from_uri("file:/tmp/inbox?move=/tmp/outside");
2957        assert!(result.is_err(), "should reject absolute move_to");
2958        let err = result.unwrap_err().to_string();
2959        assert!(
2960            err.contains("relative path") || err.contains("Invalid URI"),
2961            "error should mention invalid move_to path: {err}"
2962        );
2963    }
2964
2965    #[test]
2966    fn test_rejects_path_traversal_in_temp_prefix() {
2967        let result = FileConfig::from_uri("file:/tmp/inbox?tempPrefix=../tmp");
2968        assert!(
2969            result.is_err(),
2970            "should reject path traversal in temp_prefix"
2971        );
2972        let err = result.unwrap_err().to_string();
2973        assert!(
2974            err.contains("path traversal"),
2975            "error should mention path traversal: {err}"
2976        );
2977    }
2978
2979    #[test]
2980    fn test_rejects_temp_prefix_with_path_separator() {
2981        let result = FileConfig::from_uri("file:/tmp/inbox?tempPrefix=tmp/sub");
2982        assert!(result.is_err(), "should reject temp_prefix with separator");
2983        let err = result.unwrap_err().to_string();
2984        assert!(
2985            err.contains("plain filename prefix"),
2986            "error should mention plain filename prefix restriction: {err}"
2987        );
2988    }
2989
2990    #[test]
2991    fn test_rejects_absolute_temp_prefix() {
2992        let result = FileConfig::from_uri("file:/tmp/inbox?tempPrefix=/tmp/");
2993        assert!(result.is_err(), "should reject absolute temp_prefix");
2994        let err = result.unwrap_err().to_string();
2995        assert!(
2996            err.contains("plain filename prefix"),
2997            "error should mention plain filename prefix restriction: {err}"
2998        );
2999    }
3000
3001    #[test]
3002    fn test_rejects_null_byte_in_temp_prefix() {
3003        let config = FileConfig {
3004            directory: "/tmp/inbox".into(),
3005            delay: Duration::from_millis(500),
3006            delay_ms: 500,
3007            initial_delay: Duration::from_millis(1000),
3008            initial_delay_ms: 1000,
3009            noop: false,
3010            delete: false,
3011            move_to: None,
3012            file_name: Some("ok.txt".into()),
3013            include: None,
3014            exclude: None,
3015            ant_include: None,
3016            ant_exclude: None,
3017            include_ext: None,
3018            exclude_ext: None,
3019            recursive: false,
3020            file_exist: FileExistStrategy::Override,
3021            read_lock_strategy: ReadLockStrategy::None,
3022            idempotent_key: IdempotentKey::None,
3023            done_file_name: None,
3024            charset: None,
3025            temp_prefix: Some("tmp\0".into()),
3026            durable: false,
3027            auto_create: true,
3028            starting_directory_must_exist: false,
3029            read_timeout: Duration::from_millis(30_000),
3030            read_timeout_ms: 30_000,
3031            write_timeout: Duration::from_millis(30_000),
3032            write_timeout_ms: 30_000,
3033            max_write_bytes: 0,
3034            max_depth: usize::MAX,
3035            min_depth: 0,
3036            max_messages_per_poll: 0,
3037            eager_max_messages_per_poll: true,
3038            shuffle: false,
3039            sort_spec: None,
3040            cleanup_stale_temps: true,
3041        };
3042
3043        let result = config.validate();
3044        assert!(result.is_err(), "should reject null byte in temp_prefix");
3045    }
3046
3047    #[test]
3048    fn test_rejects_null_byte_in_filename() {
3049        // Null bytes in URI params are typically URL-encoded, so we test via
3050        // direct config construction to simulate the validation logic.
3051        let config = FileConfig {
3052            directory: "/tmp/inbox".into(),
3053            delay: Duration::from_millis(500),
3054            delay_ms: 500,
3055            initial_delay: Duration::from_millis(1000),
3056            initial_delay_ms: 1000,
3057            noop: false,
3058            delete: false,
3059            move_to: None,
3060            file_name: Some("foo\0bar".into()),
3061            include: None,
3062            exclude: None,
3063            ant_include: None,
3064            ant_exclude: None,
3065            include_ext: None,
3066            exclude_ext: None,
3067            recursive: false,
3068            file_exist: FileExistStrategy::Override,
3069            read_lock_strategy: ReadLockStrategy::None,
3070            idempotent_key: IdempotentKey::None,
3071            done_file_name: None,
3072            charset: None,
3073            temp_prefix: None,
3074            durable: false,
3075            auto_create: true,
3076            starting_directory_must_exist: false,
3077            read_timeout: Duration::from_millis(30_000),
3078            read_timeout_ms: 30_000,
3079            write_timeout: Duration::from_millis(30_000),
3080            write_timeout_ms: 30_000,
3081            max_write_bytes: 0,
3082            max_depth: usize::MAX,
3083            min_depth: 0,
3084            max_messages_per_poll: 0,
3085            eager_max_messages_per_poll: true,
3086            shuffle: false,
3087            sort_spec: None,
3088            cleanup_stale_temps: true,
3089        };
3090        let result = config.validate();
3091        assert!(result.is_err(), "should reject null byte in filename");
3092        let err = result.unwrap_err().to_string();
3093        assert!(
3094            err.contains("null"),
3095            "error should mention null bytes: {err}"
3096        );
3097    }
3098
3099    #[test]
3100    fn test_rejects_empty_filename() {
3101        let config = FileConfig {
3102            directory: "/tmp/inbox".into(),
3103            delay: Duration::from_millis(500),
3104            delay_ms: 500,
3105            initial_delay: Duration::from_millis(1000),
3106            initial_delay_ms: 1000,
3107            noop: false,
3108            delete: false,
3109            move_to: None,
3110            file_name: Some("".into()),
3111            include: None,
3112            exclude: None,
3113            ant_include: None,
3114            ant_exclude: None,
3115            include_ext: None,
3116            exclude_ext: None,
3117            recursive: false,
3118            file_exist: FileExistStrategy::Override,
3119            read_lock_strategy: ReadLockStrategy::None,
3120            idempotent_key: IdempotentKey::None,
3121            done_file_name: None,
3122            charset: None,
3123            temp_prefix: None,
3124            durable: false,
3125            auto_create: true,
3126            starting_directory_must_exist: false,
3127            read_timeout: Duration::from_millis(30_000),
3128            read_timeout_ms: 30_000,
3129            write_timeout: Duration::from_millis(30_000),
3130            write_timeout_ms: 30_000,
3131            max_write_bytes: 0,
3132            max_depth: usize::MAX,
3133            min_depth: 0,
3134            max_messages_per_poll: 0,
3135            eager_max_messages_per_poll: true,
3136            shuffle: false,
3137            sort_spec: None,
3138            cleanup_stale_temps: true,
3139        };
3140        let result = config.validate();
3141        assert!(result.is_err(), "should reject empty filename");
3142        let err = result.unwrap_err().to_string();
3143        assert!(
3144            err.contains("empty") || err.contains("must not"),
3145            "error should mention empty: {err}"
3146        );
3147    }
3148
3149    #[test]
3150    fn test_rejects_nonexistent_directory_when_starting_directory_must_exist() {
3151        let result =
3152            FileConfig::from_uri("file:/tmp/nonexistent_dir_12345?startingDirectoryMustExist=true");
3153        assert!(
3154            result.is_err(),
3155            "should reject non-existent directory when startingDirectoryMustExist=true"
3156        );
3157        let err = result.unwrap_err().to_string();
3158        assert!(
3159            err.contains("does not exist"),
3160            "error should mention directory does not exist: {err}"
3161        );
3162    }
3163
3164    #[test]
3165    fn test_accepts_existing_directory_when_starting_directory_must_exist() {
3166        let dir = tempfile::tempdir().unwrap();
3167        let dir_path = dir.path().to_str().unwrap();
3168        let result =
3169            FileConfig::from_uri(&format!("file:{dir_path}?startingDirectoryMustExist=true"));
3170        assert!(
3171            result.is_ok(),
3172            "should accept existing directory when startingDirectoryMustExist=true: {:?}",
3173            result.err()
3174        );
3175    }
3176
3177    #[test]
3178    fn test_valid_config_passes() {
3179        let cfg = FileConfig::from_uri("file:/tmp/inbox").unwrap();
3180        assert!(cfg.validate().is_ok());
3181    }
3182
3183    #[test]
3184    fn test_file_exist_strategy_rejects_unknown() {
3185        // Unknown strategy values should be rejected by from_str
3186        let result = FileExistStrategy::from_str("BogusValue");
3187        assert!(
3188            result.is_err(),
3189            "unknown FileExistStrategy should return Err"
3190        );
3191    }
3192
3193    #[test]
3194    fn test_path_contains_traversal_detects_parent_dir() {
3195        assert!(path_contains_traversal("../etc/passwd"));
3196        assert!(path_contains_traversal("foo/../bar"));
3197        assert!(path_contains_traversal(".."));
3198        assert!(path_contains_traversal("a/b/../../../c"));
3199    }
3200
3201    #[test]
3202    fn test_path_contains_traversal_accepts_safe_paths() {
3203        assert!(!path_contains_traversal("safe/path"));
3204        assert!(!path_contains_traversal("/absolute/path"));
3205        assert!(!path_contains_traversal("filename.txt"));
3206        assert!(!path_contains_traversal(".hidden"));
3207        assert!(!path_contains_traversal(""));
3208    }
3209
3210    // -----------------------------------------------------------------------
3211    // File modification detection tests (C-20)
3212    // -----------------------------------------------------------------------
3213
3214    #[tokio::test]
3215    async fn test_stream_detects_file_modified_during_read() {
3216        use futures::StreamExt;
3217
3218        let dir = tempfile::tempdir().unwrap();
3219        let file_path = dir.path().join("mutable.txt");
3220        std::fs::write(&file_path, b"initial content here").unwrap();
3221
3222        // Capture initial metadata
3223        let initial_meta = std::fs::metadata(&file_path).unwrap();
3224        let initial_size = initial_meta.len();
3225        let initial_mtime = initial_meta.modified().ok();
3226
3227        // Create a raw file stream (simulating what the consumer does)
3228        let file = tokio::fs::File::open(&file_path).await.unwrap();
3229        let raw_stream = ReaderStream::new(file).map(|res| res.map_err(CamelError::from));
3230
3231        let mut stream = ModificationDetectingStream::new(
3232            raw_stream,
3233            file_path.clone(),
3234            initial_size,
3235            initial_mtime,
3236        );
3237
3238        // Read a chunk (file not modified yet — should succeed)
3239        let chunk = stream.next().await;
3240        assert!(chunk.is_some(), "should produce at least one chunk");
3241        assert!(chunk.as_ref().unwrap().is_ok(), "first chunk should be Ok");
3242
3243        // Modify the file (change content/size)
3244        std::fs::write(&file_path, b"modified content - different size!!").unwrap();
3245
3246        // Drain remaining chunks — the stream should produce an error after EOF
3247        let mut got_error = false;
3248        while let Some(result) = stream.next().await {
3249            if let Err(err) = result {
3250                let msg = err.to_string();
3251                assert!(
3252                    msg.contains("file modified during read"),
3253                    "expected modification error, got: {msg}"
3254                );
3255                got_error = true;
3256                break;
3257            }
3258        }
3259
3260        assert!(
3261            got_error,
3262            "stream should detect file was modified during read"
3263        );
3264    }
3265
3266    #[tokio::test]
3267    async fn test_stream_succeeds_when_file_not_modified() {
3268        use futures::StreamExt;
3269
3270        let dir = tempfile::tempdir().unwrap();
3271        let file_path = dir.path().join("stable.txt");
3272        std::fs::write(&file_path, b"stable content").unwrap();
3273
3274        let initial_meta = std::fs::metadata(&file_path).unwrap();
3275        let initial_size = initial_meta.len();
3276        let initial_mtime = initial_meta.modified().ok();
3277
3278        let file = tokio::fs::File::open(&file_path).await.unwrap();
3279        let raw_stream = ReaderStream::new(file).map(|res| res.map_err(CamelError::from));
3280
3281        let mut stream = ModificationDetectingStream::new(
3282            raw_stream,
3283            file_path.clone(),
3284            initial_size,
3285            initial_mtime,
3286        );
3287
3288        // Drain entire stream — should produce no errors
3289        let mut all_ok = true;
3290        while let Some(result) = stream.next().await {
3291            if result.is_err() {
3292                all_ok = false;
3293                break;
3294            }
3295        }
3296
3297        assert!(
3298            all_ok,
3299            "stream should complete without errors when file is not modified"
3300        );
3301    }
3302
3303    // -----------------------------------------------------------------------
3304    // SortSpec::from_str tests
3305    // -----------------------------------------------------------------------
3306
3307    #[test]
3308    fn test_sort_spec_bare_field() {
3309        let spec: SortSpec = "file:name".parse().unwrap();
3310        assert_eq!(spec.groups.len(), 1);
3311        let g = &spec.groups[0];
3312        assert_eq!(g.field, SortField::Name);
3313        assert!(!g.reverse);
3314        assert!(!g.ignore_case);
3315    }
3316
3317    #[test]
3318    fn test_sort_spec_reverse() {
3319        let spec: SortSpec = "reverse:file:length".parse().unwrap();
3320        assert_eq!(spec.groups.len(), 1);
3321        let g = &spec.groups[0];
3322        assert_eq!(g.field, SortField::Length);
3323        assert!(g.reverse);
3324        assert!(!g.ignore_case);
3325    }
3326
3327    #[test]
3328    fn test_sort_spec_ignore_case() {
3329        let spec: SortSpec = "ignoreCase:file:name".parse().unwrap();
3330        assert_eq!(spec.groups.len(), 1);
3331        let g = &spec.groups[0];
3332        assert_eq!(g.field, SortField::Name);
3333        assert!(!g.reverse);
3334        assert!(g.ignore_case);
3335    }
3336
3337    #[test]
3338    fn test_sort_spec_reverse_ignore_case() {
3339        let spec: SortSpec = "reverse:ignoreCase:file:modified".parse().unwrap();
3340        assert_eq!(spec.groups.len(), 1);
3341        let g = &spec.groups[0];
3342        assert_eq!(g.field, SortField::Modified);
3343        assert!(g.reverse);
3344        assert!(g.ignore_case);
3345    }
3346
3347    #[test]
3348    fn test_sort_spec_wrong_order_rejection() {
3349        let result: Result<SortSpec, _> = "ignoreCase:reverse:file:name".parse();
3350        assert!(result.is_err());
3351        let err = result.unwrap_err();
3352        assert!(
3353            err.contains("reverse must precede"),
3354            "expected 'reverse must precede' error, got: {err}"
3355        );
3356    }
3357
3358    #[test]
3359    fn test_sort_spec_multi_group() {
3360        let spec: SortSpec = "file:name;reverse:file:length".parse().unwrap();
3361        assert_eq!(spec.groups.len(), 2);
3362        assert_eq!(spec.groups[0].field, SortField::Name);
3363        assert!(!spec.groups[0].reverse);
3364        assert_eq!(spec.groups[1].field, SortField::Length);
3365        assert!(spec.groups[1].reverse);
3366    }
3367
3368    #[test]
3369    fn test_sort_spec_unsupported_field() {
3370        let result: Result<SortSpec, _> = "file:unknown".parse();
3371        assert!(result.is_err());
3372        let err = result.unwrap_err();
3373        assert!(
3374            err.contains("unsupported"),
3375            "expected 'unsupported' error, got: {err}"
3376        );
3377    }
3378
3379    #[test]
3380    fn test_sort_spec_empty_string() {
3381        let result: Result<SortSpec, _> = "".parse();
3382        assert!(result.is_err());
3383        let err = result.unwrap_err();
3384        assert!(
3385            err.contains("at least one group"),
3386            "expected 'at least one group' error, got: {err}"
3387        );
3388    }
3389
3390    #[tokio::test]
3391    async fn test_max_depth_limits_recursion() {
3392        let dir = tempfile::tempdir().unwrap();
3393        let base = dir.path();
3394        fs::create_dir_all(base.join("sub/deep")).await.unwrap();
3395        fs::write(base.join("root.txt"), b"r").await.unwrap();
3396        fs::write(base.join("sub/mid.txt"), b"m").await.unwrap();
3397        fs::write(base.join("sub/deep/leaf.txt"), b"l")
3398            .await
3399            .unwrap();
3400
3401        let config = FileConfig::from_uri(&format!(
3402            "file://{}?recursive=true&maxDepth=2",
3403            base.display()
3404        ))
3405        .unwrap();
3406        let filters = CompiledFilters::compile(&config).unwrap();
3407        let mut seen = HashSet::new();
3408        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3409            .await
3410            .unwrap()
3411            .candidates;
3412        let names: Vec<String> = candidates
3413            .iter()
3414            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3415            .collect();
3416        assert!(names.contains(&"root.txt".to_string()));
3417        assert!(names.contains(&"mid.txt".to_string()));
3418        assert!(!names.contains(&"leaf.txt".to_string()));
3419    }
3420
3421    #[tokio::test]
3422    async fn test_min_depth_skips_base_files() {
3423        let dir = tempfile::tempdir().unwrap();
3424        let base = dir.path();
3425        fs::create_dir_all(base.join("sub")).await.unwrap();
3426        fs::write(base.join("root.txt"), b"r").await.unwrap();
3427        fs::write(base.join("sub/deep.txt"), b"d").await.unwrap();
3428
3429        let config = FileConfig::from_uri(&format!(
3430            "file://{}?recursive=true&minDepth=2",
3431            base.display()
3432        ))
3433        .unwrap();
3434        let filters = CompiledFilters::compile(&config).unwrap();
3435        let mut seen = HashSet::new();
3436        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3437            .await
3438            .unwrap()
3439            .candidates;
3440        let names: Vec<String> = candidates
3441            .iter()
3442            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3443            .collect();
3444        assert!(!names.contains(&"root.txt".to_string()));
3445        assert!(names.contains(&"deep.txt".to_string()));
3446    }
3447
3448    #[tokio::test]
3449    async fn test_include_ext_filter() {
3450        let dir = tempfile::tempdir().unwrap();
3451        let base = dir.path();
3452        fs::write(base.join("data.txt"), b"t").await.unwrap();
3453        fs::write(base.join("data.csv"), b"c").await.unwrap();
3454        fs::write(base.join("data.json"), b"j").await.unwrap();
3455
3456        let config =
3457            FileConfig::from_uri(&format!("file://{}?includeExt=txt,csv", base.display())).unwrap();
3458        let filters = CompiledFilters::compile(&config).unwrap();
3459        let mut seen = HashSet::new();
3460        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3461            .await
3462            .unwrap()
3463            .candidates;
3464        let names: Vec<String> = candidates
3465            .iter()
3466            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3467            .collect();
3468        assert_eq!(names.len(), 2);
3469        assert!(names.contains(&"data.txt".to_string()));
3470        assert!(names.contains(&"data.csv".to_string()));
3471    }
3472
3473    #[tokio::test]
3474    async fn test_ant_include_glob() {
3475        let dir = tempfile::tempdir().unwrap();
3476        let base = dir.path();
3477        fs::write(base.join("report.txt"), b"r").await.unwrap();
3478        fs::write(base.join("data.csv"), b"d").await.unwrap();
3479        fs::write(base.join("image.png"), b"i").await.unwrap();
3480
3481        let config =
3482            FileConfig::from_uri(&format!("file://{}?antInclude=*.txt,*.csv", base.display()))
3483                .unwrap();
3484        let filters = CompiledFilters::compile(&config).unwrap();
3485        let mut seen = HashSet::new();
3486        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3487            .await
3488            .unwrap()
3489            .candidates;
3490        let names: Vec<String> = candidates
3491            .iter()
3492            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3493            .collect();
3494        assert_eq!(names.len(), 2);
3495        assert!(names.contains(&"report.txt".to_string()));
3496        assert!(names.contains(&"data.csv".to_string()));
3497    }
3498
3499    #[tokio::test]
3500    async fn test_exclude_ext_filter() {
3501        let dir = tempfile::tempdir().unwrap();
3502        let base = dir.path();
3503        fs::write(base.join("keep.txt"), b"k").await.unwrap();
3504        fs::write(base.join("skip.log"), b"s").await.unwrap();
3505
3506        let config =
3507            FileConfig::from_uri(&format!("file://{}?excludeExt=log", base.display())).unwrap();
3508        let filters = CompiledFilters::compile(&config).unwrap();
3509        let mut seen = HashSet::new();
3510        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3511            .await
3512            .unwrap()
3513            .candidates;
3514        let names: Vec<String> = candidates
3515            .iter()
3516            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3517            .collect();
3518        assert_eq!(names.len(), 1);
3519        assert!(names.contains(&"keep.txt".to_string()));
3520    }
3521
3522    #[tokio::test]
3523    async fn test_ant_exclude_glob() {
3524        let dir = tempfile::tempdir().unwrap();
3525        let base = dir.path();
3526        fs::write(base.join("report.txt"), b"r").await.unwrap();
3527        fs::write(base.join("temp.tmp"), b"t").await.unwrap();
3528
3529        let config =
3530            FileConfig::from_uri(&format!("file://{}?antExclude=*.tmp", base.display())).unwrap();
3531        let filters = CompiledFilters::compile(&config).unwrap();
3532        let mut seen = HashSet::new();
3533        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3534            .await
3535            .unwrap()
3536            .candidates;
3537        let names: Vec<String> = candidates
3538            .iter()
3539            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3540            .collect();
3541        assert_eq!(names.len(), 1);
3542        assert!(names.contains(&"report.txt".to_string()));
3543    }
3544
3545    #[tokio::test]
3546    async fn test_done_file_consumer_skips_without_marker() {
3547        let dir = tempfile::tempdir().unwrap();
3548        let base = dir.path();
3549        fs::write(base.join("ready.txt"), b"ready").await.unwrap();
3550        fs::write(base.join("ready.txt.done"), b"").await.unwrap();
3551        fs::write(base.join("pending.txt"), b"pending")
3552            .await
3553            .unwrap();
3554
3555        let config = FileConfig::from_uri(&format!(
3556            "file://{}?doneFileName=${{file:name}}.done",
3557            base.display()
3558        ))
3559        .unwrap();
3560        let filters = CompiledFilters::compile(&config).unwrap();
3561        let mut seen = HashSet::new();
3562        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3563            .await
3564            .unwrap()
3565            .candidates;
3566        let names: Vec<String> = candidates
3567            .iter()
3568            .map(|c| c.path.file_name().unwrap().to_str().unwrap().to_string())
3569            .collect();
3570        assert!(names.contains(&"ready.txt".to_string()));
3571        assert!(!names.contains(&"pending.txt".to_string()));
3572        assert!(!names.contains(&"ready.txt.done".to_string()));
3573    }
3574
3575    #[tokio::test]
3576    async fn test_done_file_static_pattern() {
3577        let dir = tempfile::tempdir().unwrap();
3578        let base = dir.path();
3579        fs::write(base.join("data.txt"), b"d").await.unwrap();
3580        fs::write(base.join("ready"), b"").await.unwrap();
3581
3582        let config =
3583            FileConfig::from_uri(&format!("file://{}?doneFileName=ready", base.display())).unwrap();
3584        let filters = CompiledFilters::compile(&config).unwrap();
3585        let mut seen = HashSet::new();
3586        let candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3587            .await
3588            .unwrap()
3589            .candidates;
3590        assert_eq!(candidates.len(), 1);
3591        assert_eq!(
3592            candidates[0].path.file_name().unwrap().to_str().unwrap(),
3593            "data.txt"
3594        );
3595    }
3596
3597    // -----------------------------------------------------------------------
3598    // Sort + shuffle tests
3599    // -----------------------------------------------------------------------
3600
3601    #[tokio::test]
3602    async fn test_sort_by_name() {
3603        let dir = tempfile::tempdir().unwrap();
3604        let base = dir.path();
3605        fs::write(base.join("c.txt"), b"3").await.unwrap();
3606        fs::write(base.join("a.txt"), b"1").await.unwrap();
3607        fs::write(base.join("b.txt"), b"2").await.unwrap();
3608
3609        let config =
3610            FileConfig::from_uri(&format!("file://{}?sortBy=file:name", base.display())).unwrap();
3611        let filters = CompiledFilters::compile(&config).unwrap();
3612        let mut seen = HashSet::new();
3613        let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3614            .await
3615            .unwrap()
3616            .candidates;
3617        apply_sort_and_limit(&mut candidates, &config);
3618        let names: Vec<&str> = candidates
3619            .iter()
3620            .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3621            .collect();
3622        assert_eq!(names, vec!["a.txt", "b.txt", "c.txt"]);
3623    }
3624
3625    #[tokio::test]
3626    async fn test_sort_by_length() {
3627        let dir = tempfile::tempdir().unwrap();
3628        let base = dir.path();
3629        fs::write(base.join("big.txt"), b"xxx").await.unwrap();
3630        fs::write(base.join("small.txt"), b"x").await.unwrap();
3631
3632        let config =
3633            FileConfig::from_uri(&format!("file://{}?sortBy=file:length", base.display())).unwrap();
3634        let filters = CompiledFilters::compile(&config).unwrap();
3635        let mut seen = HashSet::new();
3636        let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3637            .await
3638            .unwrap()
3639            .candidates;
3640        apply_sort_and_limit(&mut candidates, &config);
3641        let names: Vec<&str> = candidates
3642            .iter()
3643            .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3644            .collect();
3645        assert_eq!(names, vec!["small.txt", "big.txt"]);
3646    }
3647
3648    #[tokio::test]
3649    async fn test_sort_by_reverse_name() {
3650        let dir = tempfile::tempdir().unwrap();
3651        let base = dir.path();
3652        fs::write(base.join("a.txt"), b"1").await.unwrap();
3653        fs::write(base.join("b.txt"), b"2").await.unwrap();
3654        fs::write(base.join("c.txt"), b"3").await.unwrap();
3655
3656        let config = FileConfig::from_uri(&format!(
3657            "file://{}?sortBy=reverse:file:name",
3658            base.display()
3659        ))
3660        .unwrap();
3661        let filters = CompiledFilters::compile(&config).unwrap();
3662        let mut seen = HashSet::new();
3663        let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3664            .await
3665            .unwrap()
3666            .candidates;
3667        apply_sort_and_limit(&mut candidates, &config);
3668        let names: Vec<&str> = candidates
3669            .iter()
3670            .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3671            .collect();
3672        assert_eq!(names, vec!["c.txt", "b.txt", "a.txt"]);
3673    }
3674
3675    #[tokio::test]
3676    async fn test_shuffle_deterministic() {
3677        let dir = tempfile::tempdir().unwrap();
3678        let base = dir.path();
3679        fs::write(base.join("a.txt"), b"1").await.unwrap();
3680        fs::write(base.join("b.txt"), b"2").await.unwrap();
3681        fs::write(base.join("c.txt"), b"3").await.unwrap();
3682        fs::write(base.join("d.txt"), b"4").await.unwrap();
3683
3684        let config =
3685            FileConfig::from_uri(&format!("file://{}?shuffle=true", base.display())).unwrap();
3686        let filters = CompiledFilters::compile(&config).unwrap();
3687        let mut seen = HashSet::new();
3688        let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3689            .await
3690            .unwrap()
3691            .candidates;
3692        crate::poll_logic::apply_sort_and_limit(&mut candidates, &config);
3693
3694        let mut candidates2 =
3695            crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3696                .await
3697                .unwrap()
3698                .candidates;
3699        crate::poll_logic::apply_sort_and_limit(&mut candidates2, &config);
3700
3701        let names1: Vec<&str> = candidates
3702            .iter()
3703            .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3704            .collect();
3705        let names2: Vec<&str> = candidates2
3706            .iter()
3707            .map(|c| c.path.file_name().unwrap().to_str().unwrap())
3708            .collect();
3709        assert_eq!(names1, names2);
3710        assert_ne!(names1, vec!["a.txt", "b.txt", "c.txt", "d.txt"]);
3711    }
3712
3713    #[tokio::test]
3714    async fn test_non_eager_limit() {
3715        let dir = tempfile::tempdir().unwrap();
3716        let base = dir.path();
3717        fs::write(base.join("a.txt"), b"1").await.unwrap();
3718        fs::write(base.join("b.txt"), b"2").await.unwrap();
3719        fs::write(base.join("c.txt"), b"3").await.unwrap();
3720
3721        let config = FileConfig::from_uri(&format!(
3722            "file://{}?maxMessagesPerPoll=2&eagerMaxMessagesPerPoll=false",
3723            base.display()
3724        ))
3725        .unwrap();
3726        let filters = CompiledFilters::compile(&config).unwrap();
3727        let mut seen = HashSet::new();
3728        let mut candidates = crate::poll_logic::scan_candidates(&config, &filters, base, &mut seen)
3729            .await
3730            .unwrap()
3731            .candidates;
3732        assert_eq!(candidates.len(), 3);
3733        crate::poll_logic::apply_sort_and_limit(&mut candidates, &config);
3734        assert_eq!(candidates.len(), 2);
3735    }
3736
3737    #[tokio::test]
3738    async fn test_poll_one_file_sets_all_headers() {
3739        let dir = tempfile::tempdir().unwrap();
3740        let base = dir.path();
3741        fs::write(base.join("test.txt"), b"hello world")
3742            .await
3743            .unwrap();
3744
3745        let config = FileConfig::from_uri(&format!("file:{}?noop=true", base.display())).unwrap();
3746
3747        let file_path = base.join("test.txt");
3748        let include_re = None;
3749        let exclude_re = None;
3750        let mut seen = std::collections::HashSet::new();
3751        let in_process_locks = std::sync::Arc::new(DashMap::new());
3752        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
3753
3754        let result = poll_one_file(
3755            &config,
3756            file_path.clone(),
3757            base,
3758            &include_re,
3759            &exclude_re,
3760            &mut seen,
3761            &in_process_locks,
3762            &idempotent_repo,
3763        )
3764        .await
3765        .unwrap();
3766
3767        let exchange = result.expect("poll_one_file should return an exchange");
3768
3769        let camel_file_name = exchange
3770            .input
3771            .header("CamelFileName")
3772            .and_then(|v| v.as_str().map(String::from))
3773            .expect("CamelFileName header");
3774        let camel_file_name_only = exchange
3775            .input
3776            .header("CamelFileNameOnly")
3777            .and_then(|v| v.as_str().map(String::from))
3778            .expect("CamelFileNameOnly header");
3779        let camel_file_absolute_path = exchange
3780            .input
3781            .header("CamelFileAbsolutePath")
3782            .and_then(|v| v.as_str().map(String::from))
3783            .expect("CamelFileAbsolutePath header");
3784        exchange
3785            .input
3786            .header("CamelFileLength")
3787            .and_then(|v| v.as_u64())
3788            .expect("CamelFileLength header");
3789        exchange
3790            .input
3791            .header("CamelFileLastModified")
3792            .and_then(|v| v.as_u64())
3793            .expect("CamelFileLastModified header");
3794        let camel_file_path = exchange
3795            .input
3796            .header("CamelFilePath")
3797            .and_then(|v| v.as_str().map(String::from))
3798            .expect("CamelFilePath header");
3799        let camel_file_parent = exchange
3800            .input
3801            .header("CamelFileParent")
3802            .and_then(|v| v.as_str().map(String::from))
3803            .expect("CamelFileParent header");
3804        let camel_file_canonical_path = exchange
3805            .input
3806            .header("CamelFileCanonicalPath")
3807            .and_then(|v| v.as_str().map(String::from))
3808            .expect("CamelFileCanonicalPath header");
3809        let camel_file_relative_path = exchange
3810            .input
3811            .header("CamelFileRelativePath")
3812            .and_then(|v| v.as_str().map(String::from))
3813            .expect("CamelFileRelativePath header");
3814
3815        assert_eq!(camel_file_name, "test.txt");
3816        assert_eq!(camel_file_name_only, "test.txt");
3817        assert_eq!(camel_file_relative_path, camel_file_name);
3818        assert_eq!(camel_file_path, base.to_string_lossy());
3819        assert_eq!(camel_file_parent, base.to_string_lossy());
3820        assert!(camel_file_absolute_path.ends_with("test.txt"));
3821        assert!(camel_file_canonical_path.ends_with("test.txt"));
3822    }
3823
3824    #[test]
3825    fn test_try_rename_requires_temp_prefix() {
3826        let result = FileConfig::from_uri("file:///tmp?fileExist=TryRename");
3827        assert!(result.is_err());
3828        assert!(
3829            result
3830                .unwrap_err()
3831                .to_string()
3832                .contains("TryRename requires tempPrefix")
3833        );
3834    }
3835
3836    #[test]
3837    fn test_try_rename_with_temp_prefix_ok() {
3838        let result = FileConfig::from_uri("file:///tmp?fileExist=TryRename&tempPrefix=tmp_");
3839        assert!(result.is_ok());
3840        assert_eq!(result.unwrap().file_exist, FileExistStrategy::TryRename);
3841    }
3842
3843    #[tokio::test]
3844    async fn test_try_rename_producer_writes_file() {
3845        use tower::ServiceExt;
3846
3847        let dir = tempfile::tempdir().unwrap();
3848        let base = dir.path();
3849
3850        let component = FileComponent::new();
3851        let ctx = NoOpComponentContext;
3852        let endpoint = component
3853            .create_endpoint(
3854                &format!(
3855                    "file://{}?fileExist=TryRename&tempPrefix=tmp_",
3856                    base.display()
3857                ),
3858                &ctx,
3859            )
3860            .unwrap();
3861        let ctx = test_producer_ctx();
3862        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3863
3864        let mut exchange = Exchange::new(Message::new("hello"));
3865        exchange.input.set_header(
3866            "CamelFileName",
3867            serde_json::Value::String("output.txt".to_string()),
3868        );
3869
3870        producer.oneshot(exchange).await.unwrap();
3871
3872        let target = base.join("output.txt");
3873        assert!(target.exists(), "output file should exist");
3874        let content = std::fs::read_to_string(&target).unwrap();
3875        assert_eq!(content, "hello");
3876
3877        let tmp_files: Vec<_> = std::fs::read_dir(base)
3878            .unwrap()
3879            .filter_map(|e| e.ok())
3880            .filter(|e| e.file_name().to_string_lossy().starts_with("tmp_"))
3881            .collect();
3882        assert!(tmp_files.is_empty(), "no temp files should remain");
3883    }
3884
3885    // -----------------------------------------------------------------------
3886    // Integration tests: scan_candidates + apply_sort_and_limit pipeline
3887    // -----------------------------------------------------------------------
3888
3889    #[tokio::test]
3890    async fn test_pipeline_sort_then_limit() {
3891        let dir = tempfile::tempdir().unwrap();
3892        let base = dir.path();
3893        fs::write(base.join("c.txt"), b"ccc").await.unwrap();
3894        fs::write(base.join("a.txt"), b"a").await.unwrap();
3895        fs::write(base.join("b.txt"), b"bb").await.unwrap();
3896
3897        let config = FileConfig::from_uri(&format!(
3898            "file://{}?sortBy=file:length&maxMessagesPerPoll=2&eagerMaxMessagesPerPoll=false",
3899            base.display()
3900        ))
3901        .unwrap();
3902        let filters = CompiledFilters::compile(&config).unwrap();
3903        let mut seen = HashSet::new();
3904        let mut candidates = scan_candidates(&config, &filters, base, &mut seen)
3905            .await
3906            .unwrap()
3907            .candidates;
3908        apply_sort_and_limit(&mut candidates, &config);
3909        assert_eq!(candidates.len(), 2);
3910        assert_eq!(
3911            candidates[0].path.file_name().unwrap().to_str().unwrap(),
3912            "a.txt"
3913        );
3914        assert_eq!(
3915            candidates[1].path.file_name().unwrap().to_str().unwrap(),
3916            "b.txt"
3917        );
3918    }
3919
3920    #[tokio::test]
3921    async fn test_sort_reverse_ignore_case() {
3922        let dir = tempfile::tempdir().unwrap();
3923        let base = dir.path();
3924        fs::write(base.join("Alpha.txt"), b"a").await.unwrap();
3925        fs::write(base.join("beta.txt"), b"b").await.unwrap();
3926
3927        let config = FileConfig::from_uri(&format!(
3928            "file://{}?sortBy=reverse:ignoreCase:file:name",
3929            base.display()
3930        ))
3931        .unwrap();
3932        let filters = CompiledFilters::compile(&config).unwrap();
3933        let mut seen = HashSet::new();
3934        let mut candidates = scan_candidates(&config, &filters, base, &mut seen)
3935            .await
3936            .unwrap()
3937            .candidates;
3938        apply_sort_and_limit(&mut candidates, &config);
3939        assert_eq!(
3940            candidates[0].path.file_name().unwrap().to_str().unwrap(),
3941            "beta.txt"
3942        );
3943    }
3944
3945    #[tokio::test]
3946    async fn test_done_file_noop_no_deletion() {
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("done"), b"").await.unwrap();
3951
3952        let config = FileConfig::from_uri(&format!(
3953            "file://{}?doneFileName=done&noop=true",
3954            base.display()
3955        ))
3956        .unwrap();
3957        let filters = CompiledFilters::compile(&config).unwrap();
3958        let mut seen = HashSet::new();
3959        let candidates = scan_candidates(&config, &filters, base, &mut seen)
3960            .await
3961            .unwrap()
3962            .candidates;
3963        assert_eq!(candidates.len(), 1);
3964        assert!(base.join("done").exists());
3965    }
3966
3967    #[tokio::test]
3968    async fn test_static_done_file_deleted_after_all_processed() {
3969        let dir = tempfile::tempdir().unwrap();
3970        let base = dir.path();
3971        fs::write(base.join("data.txt"), b"d").await.unwrap();
3972        fs::write(base.join("ready"), b"").await.unwrap();
3973
3974        let config = FileConfig::from_uri(&format!(
3975            "file://{}?doneFileName=ready&delete=true",
3976            base.display()
3977        ))
3978        .unwrap();
3979        let filters = CompiledFilters::compile(&config).unwrap();
3980        let mut seen = HashSet::new();
3981        let in_process_locks = std::sync::Arc::new(DashMap::new());
3982        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
3983        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
3984        let token = CancellationToken::new();
3985        let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
3986
3987        poll_directory(
3988            &config,
3989            &ctx,
3990            &rt(),
3991            &filters,
3992            &mut seen,
3993            &in_process_locks,
3994            &idempotent_repo,
3995        )
3996        .await
3997        .unwrap();
3998
3999        assert!(rx.try_recv().is_ok());
4000        assert!(!base.join("ready").exists());
4001    }
4002
4003    #[tokio::test]
4004    async fn test_static_done_file_not_deleted_when_limited() {
4005        let dir = tempfile::tempdir().unwrap();
4006        let base = dir.path();
4007        fs::write(base.join("a.txt"), b"a").await.unwrap();
4008        fs::write(base.join("b.txt"), b"b").await.unwrap();
4009        fs::write(base.join("ready"), b"").await.unwrap();
4010
4011        let config = FileConfig::from_uri(&format!(
4012            "file://{}?doneFileName=ready&delete=true&maxMessagesPerPoll=1&eagerMaxMessagesPerPoll=true",
4013            base.display()
4014        ))
4015        .unwrap();
4016        let filters = CompiledFilters::compile(&config).unwrap();
4017        let mut seen = HashSet::new();
4018        let in_process_locks = std::sync::Arc::new(DashMap::new());
4019        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
4020        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
4021        let token = CancellationToken::new();
4022        let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
4023
4024        poll_directory(
4025            &config,
4026            &ctx,
4027            &rt(),
4028            &filters,
4029            &mut seen,
4030            &in_process_locks,
4031            &idempotent_repo,
4032        )
4033        .await
4034        .unwrap();
4035
4036        let _ = rx.try_recv();
4037        assert!(base.join("ready").exists());
4038    }
4039
4040    #[tokio::test]
4041    async fn test_dynamic_done_file_deleted_per_file() {
4042        let dir = tempfile::tempdir().unwrap();
4043        let base = dir.path();
4044        fs::write(base.join("data.txt"), b"d").await.unwrap();
4045        fs::write(base.join("data.txt.done"), b"").await.unwrap();
4046
4047        let config = FileConfig::from_uri(&format!(
4048            "file://{}?doneFileName=${{file:name}}.done&delete=true",
4049            base.display()
4050        ))
4051        .unwrap();
4052        let filters = CompiledFilters::compile(&config).unwrap();
4053        let mut seen = HashSet::new();
4054        let in_process_locks = std::sync::Arc::new(DashMap::new());
4055        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
4056        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
4057        let token = CancellationToken::new();
4058        let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
4059
4060        poll_directory(
4061            &config,
4062            &ctx,
4063            &rt(),
4064            &filters,
4065            &mut seen,
4066            &in_process_locks,
4067            &idempotent_repo,
4068        )
4069        .await
4070        .unwrap();
4071
4072        assert!(rx.try_recv().is_ok());
4073        assert!(!base.join("data.txt.done").exists());
4074    }
4075
4076    // -----------------------------------------------------------------------
4077    // Security: adversarial path tests (audit 2026-08-31, findings F4-1/F4-2)
4078    // -----------------------------------------------------------------------
4079
4080    /// F4-1: an absolute CamelFileName header must NOT let the done file escape
4081    /// the endpoint directory (Path::join would otherwise discard the base).
4082    #[tokio::test]
4083    async fn test_done_file_rejects_absolute_file_name_header() {
4084        use tower::ServiceExt;
4085
4086        let dir = tempfile::tempdir().unwrap();
4087        let dir_path = dir.path().to_str().unwrap();
4088        let outside = dir.path().parent().unwrap().join("pwn.done");
4089
4090        let component = FileComponent::new();
4091        let ctx = NoOpComponentContext;
4092        let endpoint = component
4093            .create_endpoint(
4094                &format!("file:{dir_path}?doneFileName=${{file:name}}.done"),
4095                &ctx,
4096            )
4097            .unwrap();
4098        let ctx = test_producer_ctx();
4099        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4100
4101        let mut exchange = Exchange::new(Message::new("data"));
4102        exchange.input.set_header(
4103            "CamelFileName",
4104            serde_json::Value::String("/tmp/pwn".to_string()),
4105        );
4106
4107        let result = producer.oneshot(exchange).await;
4108        assert!(result.is_err(), "absolute CamelFileName must be rejected");
4109        assert!(
4110            !outside.exists() && !std::path::Path::new("/tmp/pwn.done").exists(),
4111            "no done file may be created outside the base directory"
4112        );
4113    }
4114
4115    /// F4-1: traversal in CamelFileName must not let the done file escape.
4116    #[tokio::test]
4117    async fn test_done_file_rejects_traversal_in_file_name_header() {
4118        use tower::ServiceExt;
4119
4120        let dir = tempfile::tempdir().unwrap();
4121        let dir_path = dir.path().to_str().unwrap();
4122
4123        let component = FileComponent::new();
4124        let ctx = NoOpComponentContext;
4125        let endpoint = component
4126            .create_endpoint(
4127                &format!("file:{dir_path}?doneFileName=${{file:name}}.done"),
4128                &ctx,
4129            )
4130            .unwrap();
4131        let ctx = test_producer_ctx();
4132        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4133
4134        let mut exchange = Exchange::new(Message::new("data"));
4135        exchange.input.set_header(
4136            "CamelFileName",
4137            serde_json::Value::String("../escape".to_string()),
4138        );
4139
4140        let result = producer.oneshot(exchange).await;
4141        assert!(
4142            result.is_err(),
4143            "traversal in CamelFileName must be rejected"
4144        );
4145        assert!(
4146            !dir.path().parent().unwrap().join("escape.done").exists(),
4147            "no done file may be created outside the base directory"
4148        );
4149    }
4150
4151    /// F4-2: fileExist=Append must refuse to follow a symlink leaf, even when
4152    /// the symlink points to a path that does not exist yet (dangling).
4153    #[cfg(unix)]
4154    #[tokio::test]
4155    async fn test_append_refuses_dangling_symlink_leaf() {
4156        use tower::ServiceExt;
4157
4158        let dir = tempfile::tempdir().unwrap();
4159        let dir_path = dir.path().to_str().unwrap();
4160        let outside = dir.path().parent().unwrap().join("escape_target");
4161
4162        // Plant a dangling symlink inside the base dir pointing outside.
4163        std::os::unix::fs::symlink(&outside, dir.path().join("link")).unwrap();
4164
4165        let component = FileComponent::new();
4166        let ctx = NoOpComponentContext;
4167        let endpoint = component
4168            .create_endpoint(&format!("file:{dir_path}?fileExist=Append"), &ctx)
4169            .unwrap();
4170        let ctx = test_producer_ctx();
4171        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4172
4173        let mut exchange = Exchange::new(Message::new("attacker bytes"));
4174        exchange.input.set_header(
4175            "CamelFileName",
4176            serde_json::Value::String("link".to_string()),
4177        );
4178
4179        let result = producer.oneshot(exchange).await;
4180        assert!(
4181            result.is_err(),
4182            "Append must refuse to open a symlink leaf (O_NOFOLLOW)"
4183        );
4184        assert!(
4185            !outside.exists(),
4186            "no file may be created outside the base via dangling symlink"
4187        );
4188    }
4189
4190    /// F4-2: fileExist=Fail must refuse a symlink leaf too (create_new already
4191    /// fails, but the refusal must remain under O_NOFOLLOW semantics).
4192    #[cfg(unix)]
4193    #[tokio::test]
4194    async fn test_fail_refuses_symlink_leaf() {
4195        use tower::ServiceExt;
4196
4197        let dir = tempfile::tempdir().unwrap();
4198        let dir_path = dir.path().to_str().unwrap();
4199        let outside = dir.path().parent().unwrap().join("fail_escape_target");
4200        std::fs::write(&outside, b"precious").unwrap();
4201
4202        // Symlink inside base -> existing file outside base.
4203        std::os::unix::fs::symlink(&outside, dir.path().join("link")).unwrap();
4204
4205        let component = FileComponent::new();
4206        let ctx = NoOpComponentContext;
4207        let endpoint = component
4208            .create_endpoint(&format!("file:{dir_path}?fileExist=Fail"), &ctx)
4209            .unwrap();
4210        let ctx = test_producer_ctx();
4211        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4212
4213        let mut exchange = Exchange::new(Message::new("clobber"));
4214        exchange.input.set_header(
4215            "CamelFileName",
4216            serde_json::Value::String("link".to_string()),
4217        );
4218
4219        let result = producer.oneshot(exchange).await;
4220        assert!(result.is_err(), "Fail must refuse a symlink leaf");
4221        assert_eq!(
4222            std::fs::read(&outside).unwrap(),
4223            b"precious",
4224            "outside file must remain untouched"
4225        );
4226    }
4227
4228    /// Unit: the lexical pre-validator rejects absolute paths, traversal and NUL.
4229    #[test]
4230    fn test_validate_relative_filename_rejects_evil_values() {
4231        assert!(validate_relative_filename("/etc/passwd", "fileName").is_err());
4232        assert!(validate_relative_filename("../x", "fileName").is_err());
4233        assert!(validate_relative_filename("a/../../x", "fileName").is_err());
4234        assert!(validate_relative_filename("a\0b", "fileName").is_err());
4235        assert!(validate_relative_filename("", "fileName").is_err());
4236        assert!(validate_relative_filename("ok/nested.txt", "fileName").is_ok());
4237    }
4238
4239    // -------------------------------------------------------------------------
4240    // validate_path_is_within_base: symlinked-component confinement
4241    // (file-ancestor-confinement, Task 1.1)
4242    // -------------------------------------------------------------------------
4243
4244    /// A symlinked intermediate component below base must be rejected even
4245    /// when nothing below it exists yet.
4246    #[cfg(unix)]
4247    #[test]
4248    fn validator_rejects_symlinked_ancestor() {
4249        let base = tempfile::tempdir().unwrap();
4250        let outside = tempfile::tempdir().unwrap();
4251        std::os::unix::fs::symlink(outside.path(), base.path().join("link")).unwrap();
4252
4253        let target = base.path().join("link").join("new").join("f.txt");
4254        let err = validate_path_is_within_base(base.path(), &target)
4255            .unwrap_err()
4256            .to_string();
4257        assert!(
4258            err.contains("symlinked component"),
4259            "error must name the symlinked component, got: {err}"
4260        );
4261        assert!(
4262            err.contains(&base.path().join("link").display().to_string()),
4263            "error must name the symlinked component 'link', got: {err}"
4264        );
4265    }
4266
4267    /// An in-base symlink alias (`base/alias -> base/real`) is a component
4268    /// below the configured base and must be rejected even though it
4269    /// resolves inside the canonicalized base.
4270    #[cfg(unix)]
4271    #[test]
4272    fn validator_rejects_in_base_alias() {
4273        let base = tempfile::tempdir().unwrap();
4274        std::fs::create_dir(base.path().join("real")).unwrap();
4275        std::os::unix::fs::symlink(base.path().join("real"), base.path().join("alias")).unwrap();
4276
4277        let target = base.path().join("alias").join("new").join("f.txt");
4278        let err = validate_path_is_within_base(base.path(), &target)
4279            .unwrap_err()
4280            .to_string();
4281        assert!(
4282            err.contains("symlinked component"),
4283            "in-base alias must be rejected, got: {err}"
4284        );
4285        assert!(
4286            err.contains(&base.path().join("alias").display().to_string()),
4287            "error must name the symlinked component 'alias', got: {err}"
4288        );
4289    }
4290
4291    /// A deep not-yet-existing path with no symlinks stays accepted: the
4292    /// nearest-existing-ancestor containment degenerates to the base itself.
4293    #[test]
4294    fn validator_accepts_missing_deep_path() {
4295        let base = tempfile::tempdir().unwrap();
4296        let target = base.path().join("a").join("b").join("c.txt");
4297        validate_path_is_within_base(base.path(), &target).unwrap();
4298    }
4299
4300    /// The base directory itself may be a symlink; only components below
4301    /// the configured base are subject to symlink rejection.
4302    #[cfg(unix)]
4303    #[test]
4304    fn validator_accepts_symlinked_base() {
4305        let parent = tempfile::tempdir().unwrap();
4306        std::fs::create_dir(parent.path().join("real")).unwrap();
4307        std::os::unix::fs::symlink(parent.path().join("real"), parent.path().join("base")).unwrap();
4308
4309        let base = parent.path().join("base");
4310        let target = base.join("x.txt");
4311        validate_path_is_within_base(&base, &target).unwrap();
4312    }
4313
4314    /// Regression guard: an existing target reached through
4315    /// `base/link -> outside` must be rejected by the symlink-chain scan
4316    /// (which fires before the ancestor-containment check).
4317    #[cfg(unix)]
4318    #[test]
4319    fn validator_rejects_existing_outside_target() {
4320        let base = tempfile::tempdir().unwrap();
4321        let outside = tempfile::tempdir().unwrap();
4322        std::fs::write(outside.path().join("existing.txt"), b"x").unwrap();
4323        std::os::unix::fs::symlink(outside.path(), base.path().join("link")).unwrap();
4324
4325        let target = base.path().join("link").join("existing.txt");
4326        let err = validate_path_is_within_base(base.path(), &target)
4327            .unwrap_err()
4328            .to_string();
4329        assert!(
4330            err.contains("symlinked component"),
4331            "chain scan must fire before containment, got: {err}"
4332        );
4333    }
4334
4335    // -------------------------------------------------------------------------
4336    // Producer regression tests: symlinked-ancestor confinement end-to-end
4337    // (file-ancestor-confinement, Task 1.2)
4338    // -------------------------------------------------------------------------
4339
4340    /// Producer-level regression tests for symlink confinement. Each test
4341    /// drives `FileProducer::call` end-to-end through the component harness
4342    /// and asserts that a symlinked component below the configured base is
4343    /// rejected before any filesystem artifact is produced outside it, while
4344    /// legitimate nested creation and a symlinked base itself stay accepted.
4345    #[cfg(unix)]
4346    mod producer_symlink_confinement {
4347        use super::*;
4348        use tower::ServiceExt;
4349
4350        /// Shared arrange: `base/` and `outside/` tempdirs on the same
4351        /// filesystem, with `base/link -> outside` planted (unless
4352        /// [`SymlinkFixture::new_without_link`]).
4353        struct SymlinkFixture {
4354            base: tempfile::TempDir,
4355            outside: tempfile::TempDir,
4356        }
4357
4358        impl SymlinkFixture {
4359            fn new() -> Self {
4360                Self::build(true)
4361            }
4362
4363            /// Variant without the planted symlink, for the plain
4364            /// no-symlinks success case.
4365            fn new_without_link() -> Self {
4366                Self::build(false)
4367            }
4368
4369            fn build(with_link: bool) -> Self {
4370                let base = tempfile::tempdir().unwrap();
4371                let outside = tempfile::tempdir().unwrap();
4372                if with_link {
4373                    std::os::unix::fs::symlink(outside.path(), base.path().join("link")).unwrap();
4374                }
4375                Self { base, outside }
4376            }
4377
4378            fn base(&self) -> &std::path::Path {
4379                self.base.path()
4380            }
4381
4382            fn outside(&self) -> &std::path::Path {
4383                self.outside.path()
4384            }
4385
4386            /// Config builder matching the existing producer-test shape:
4387            /// endpoint on this fixture's base with the given URI query.
4388            fn producer(&self, query: &str) -> BoxProcessor {
4389                make_producer(self.base(), query)
4390            }
4391
4392            /// `outside/` must contain no artifacts at all.
4393            fn assert_outside_untouched(&self) {
4394                let entries: Vec<_> = std::fs::read_dir(self.outside())
4395                    .unwrap()
4396                    .collect::<Result<_, _>>()
4397                    .unwrap();
4398                assert!(
4399                    entries.is_empty(),
4400                    "outside/ must contain no artifacts, got: {:?}",
4401                    entries.iter().map(|e| e.path()).collect::<Vec<_>>()
4402                );
4403            }
4404        }
4405
4406        /// Build a producer on `dir_path` the same way the existing producer
4407        /// tests do: `FileComponent` -> endpoint URI -> `create_producer`.
4408        fn make_producer(dir_path: &std::path::Path, query: &str) -> BoxProcessor {
4409            let component = FileComponent::new();
4410            let ctx = NoOpComponentContext;
4411            let endpoint = component
4412                .create_endpoint(&format!("file:{}{query}", dir_path.display()), &ctx)
4413                .unwrap();
4414            let ctx = test_producer_ctx();
4415            endpoint.create_producer(rt(), &ctx).unwrap()
4416        }
4417
4418        /// Drive `FileProducer::call` with a text body and a `CamelFileName`
4419        /// header via `oneshot`.
4420        async fn call(
4421            producer: BoxProcessor,
4422            file_name: &str,
4423            body: &str,
4424        ) -> Result<Exchange, CamelError> {
4425            let mut exchange = Exchange::new(Message::new(body));
4426            exchange
4427                .input
4428                .set_header("CamelFileName", serde_json::Value::String(file_name.into()));
4429            producer.oneshot(exchange).await
4430        }
4431
4432        /// For EVERY fileExist strategy, a fileName whose intermediate
4433        /// component is a symlink below the base must be rejected with the
4434        /// confinement violation, and the symlink target must stay empty.
4435        #[tokio::test]
4436        async fn producer_rejects_symlinked_ancestor_all_strategies() {
4437            for strategy in ["Fail", "Append", "Override", "TryRename"] {
4438                let fixture = SymlinkFixture::new();
4439                let query = if strategy == "TryRename" {
4440                    format!("?fileExist={strategy}&tempPrefix=tmp-")
4441                } else {
4442                    format!("?fileExist={strategy}")
4443                };
4444                let producer = fixture.producer(&query);
4445
4446                let Err(err) = call(producer, "link/new/file.txt", "payload").await else {
4447                    panic!("fileExist={strategy}: symlinked ancestor must be rejected");
4448                };
4449                let msg = err.to_string();
4450                assert!(
4451                    msg.contains("symlinked component"),
4452                    "fileExist={strategy}: error must mention the confinement violation, got: {msg}"
4453                );
4454                assert!(
4455                    !fixture.outside().join("new").exists(),
4456                    "fileExist={strategy}: outside/new/ must not exist"
4457                );
4458                fixture.assert_outside_untouched();
4459            }
4460        }
4461
4462        /// A safe body fileName plus an independently symlinked doneFileName
4463        /// ancestor must fail the exchange without creating the done file in
4464        /// the symlink target.
4465        #[tokio::test]
4466        async fn producer_done_file_rejects_symlinked_ancestor() {
4467            let fixture = SymlinkFixture::new();
4468            let producer = fixture.producer("?doneFileName=link/done-marker");
4469
4470            let Err(err) = call(producer, "body.txt", "payload").await else {
4471                panic!("symlinked doneFileName ancestor must be rejected");
4472            };
4473            let msg = err.to_string();
4474            assert!(
4475                msg.contains("symlinked component"),
4476                "error must mention the confinement violation, got: {msg}"
4477            );
4478            // The body write precedes done-file validation, so it must have
4479            // landed before the rejection (pins error provenance to the
4480            // done-file path, not the write path).
4481            assert!(
4482                fixture.base().join("body.txt").exists(),
4483                "base/body.txt must exist: body write precedes done-file validation"
4484            );
4485            assert!(
4486                !fixture.outside().join("done-marker").exists(),
4487                "outside/done-marker must not exist"
4488            );
4489            fixture.assert_outside_untouched();
4490        }
4491
4492        /// fileExist=Ignore must NOT degrade to the early no-op success when
4493        /// the existing target is reached through a symlinked ancestor; the
4494        /// probe must be rejected and the target file left unchanged.
4495        #[tokio::test]
4496        async fn producer_ignore_probe_rejects_symlinked_ancestor() {
4497            let fixture = SymlinkFixture::new();
4498            std::fs::write(fixture.outside().join("existing.txt"), b"original").unwrap();
4499
4500            let producer = fixture.producer("?fileExist=Ignore");
4501            let Err(err) = call(producer, "link/existing.txt", "probe").await else {
4502                panic!("Ignore probe via symlinked ancestor must be rejected");
4503            };
4504            let msg = err.to_string();
4505            assert!(
4506                msg.contains("symlinked component"),
4507                "error must mention the confinement violation, got: {msg}"
4508            );
4509
4510            let content = std::fs::read(fixture.outside().join("existing.txt")).unwrap();
4511            assert_eq!(
4512                content, b"original",
4513                "outside/existing.txt content must be unchanged"
4514            );
4515        }
4516
4517        /// An in-base symlink alias (`base/alias -> base/real`) is a
4518        /// symlinked component below the base and must be rejected even
4519        /// though it resolves inside the base.
4520        #[tokio::test]
4521        async fn producer_in_base_alias_rejected() {
4522            let fixture = SymlinkFixture::new();
4523            std::fs::create_dir(fixture.base().join("real")).unwrap();
4524            std::os::unix::fs::symlink(fixture.base().join("real"), fixture.base().join("alias"))
4525                .unwrap();
4526
4527            let producer = fixture.producer(""); // default Override
4528            let Err(err) = call(producer, "alias/f.txt", "payload").await else {
4529                panic!("in-base alias must be rejected");
4530            };
4531            let msg = err.to_string();
4532            assert!(
4533                msg.contains("symlinked component") && msg.contains("alias"),
4534                "error must name the symlinked component 'alias', got: {msg}"
4535            );
4536        }
4537
4538        /// No over-blocking: plain nested directory auto-creation with no
4539        /// symlinks anywhere must still succeed.
4540        #[tokio::test]
4541        async fn producer_nested_new_dirs_still_succeed() {
4542            let fixture = SymlinkFixture::new_without_link();
4543            let producer = fixture.producer("?autoCreate=true");
4544
4545            call(producer, "a/b/c.txt", "nested body")
4546                .await
4547                .expect("nested auto-created directories must succeed");
4548
4549            let content = std::fs::read_to_string(fixture.base().join("a/b/c.txt")).unwrap();
4550            assert_eq!(content, "nested body");
4551        }
4552
4553        /// No over-blocking: the base directory ITSELF may be a symlink
4554        /// (operator contract); writes through it must be accepted and land
4555        /// in the real directory.
4556        #[tokio::test]
4557        async fn producer_symlinked_base_still_accepted() {
4558            let parent = tempfile::tempdir().unwrap();
4559            std::fs::create_dir(parent.path().join("real")).unwrap();
4560            std::os::unix::fs::symlink(parent.path().join("real"), parent.path().join("base"))
4561                .unwrap();
4562
4563            let producer = make_producer(&parent.path().join("base"), "");
4564            call(producer, "x.txt", "payload")
4565                .await
4566                .expect("a symlinked base directory must be accepted");
4567
4568            let content =
4569                std::fs::read_to_string(parent.path().join("real").join("x.txt")).unwrap();
4570            assert_eq!(
4571                content, "payload",
4572                "file must be written under the real dir"
4573            );
4574        }
4575    }
4576
4577    #[test]
4578    fn uri_options_count_parity() {
4579        assert_eq!(
4580            FileConfig::uri_options().len(),
4581            32,
4582            "FileUriConfig #[uri_param] count drifted from parser"
4583        );
4584    }
4585
4586    // -------------------------------------------------------------------------
4587    // Recording metrics collector for testing increment_errors calls
4588    // (pattern: camel-direct tests::RecordingMetrics)
4589    // -------------------------------------------------------------------------
4590
4591    struct RecordingMetrics {
4592        errors: Arc<std::sync::Mutex<Vec<(String, String)>>>,
4593    }
4594
4595    impl camel_api::MetricsCollector for RecordingMetrics {
4596        fn record_exchange_duration(&self, _: &str, _: Duration) {}
4597        fn increment_errors(&self, route_id: &str, error_type: &str) {
4598            self.errors
4599                .lock()
4600                .unwrap()
4601                .push((route_id.to_string(), error_type.to_string()));
4602        }
4603        fn increment_exchanges(&self, _: &str) {}
4604        fn set_queue_depth(&self, _: &str, _: usize) {}
4605        fn record_circuit_breaker_change(&self, _: &str, _: &str, _: &str) {}
4606    }
4607
4608    struct RecordingRuntime {
4609        metrics_collector: Arc<RecordingMetrics>,
4610    }
4611
4612    impl RecordingRuntime {
4613        fn new(errors: Arc<std::sync::Mutex<Vec<(String, String)>>>) -> Self {
4614            Self {
4615                metrics_collector: Arc::new(RecordingMetrics { errors }),
4616            }
4617        }
4618    }
4619
4620    impl camel_component_api::RuntimeObservability for RecordingRuntime {
4621        fn metrics(&self) -> Arc<dyn camel_api::MetricsCollector> {
4622            self.metrics_collector.clone() as Arc<dyn camel_api::MetricsCollector>
4623        }
4624        fn health(&self) -> Arc<dyn camel_component_api::HealthCheckRegistry> {
4625            panic!("RecordingRuntime::health not used in this test")
4626        }
4627    }
4628
4629    #[tokio::test]
4630    async fn file_poll_send_failure_counts_b_prime() {
4631        // Positive: a poll whose pipeline send fails (receiver dropped) counts
4632        // exactly one `b-prime:file:poll-send` emission and propagates
4633        // ChannelClosed.
4634        let dir = tempfile::tempdir().unwrap();
4635        let base = dir.path();
4636        std::fs::write(base.join("data.txt"), b"payload").unwrap();
4637
4638        let config = FileConfig::from_uri(&format!("file:{}", base.display())).unwrap();
4639        let filters = CompiledFilters::compile(&config).unwrap();
4640        let mut seen = HashSet::new();
4641        let in_process_locks = std::sync::Arc::new(DashMap::new());
4642        let idempotent_repo = std::sync::Arc::new(tokio::sync::Mutex::new(HashSet::new()));
4643
4644        let (tx, rx) = tokio::sync::mpsc::channel(16);
4645        drop(rx); // pipeline receiver gone → context.send fails
4646        let token = CancellationToken::new();
4647        let ctx = ConsumerContext::new(tx, token, "file-test-route".to_string());
4648
4649        let errors = Arc::new(std::sync::Mutex::new(Vec::new()));
4650        let runtime: Arc<dyn camel_component_api::RuntimeObservability> =
4651            Arc::new(RecordingRuntime::new(Arc::clone(&errors)));
4652
4653        let result = poll_directory(
4654            &config,
4655            &ctx,
4656            &runtime,
4657            &filters,
4658            &mut seen,
4659            &in_process_locks,
4660            &idempotent_repo,
4661        )
4662        .await;
4663
4664        assert!(matches!(result, Err(CamelError::ChannelClosed)));
4665        let recorded = errors.lock().unwrap().clone();
4666        assert_eq!(
4667            recorded,
4668            vec![(
4669                "file-test-route".to_string(),
4670                "b-prime:file:poll-send".to_string()
4671            )]
4672        );
4673
4674        // Negative: a scan failure (directory gone) must NOT emit the
4675        // poll-send label — the failure is not a dispatch send.
4676        let gone = tempfile::tempdir().unwrap();
4677        let gone_path = gone.path().to_path_buf();
4678        let scan_config = FileConfig::from_uri(&format!("file:{}", gone_path.display())).unwrap();
4679        let scan_filters = CompiledFilters::compile(&scan_config).unwrap();
4680        drop(gone); // directory no longer exists → scan_candidates fails
4681
4682        let mut scan_seen = HashSet::new();
4683        let scan_errors = Arc::new(std::sync::Mutex::new(Vec::new()));
4684        let scan_runtime: Arc<dyn camel_component_api::RuntimeObservability> =
4685            Arc::new(RecordingRuntime::new(Arc::clone(&scan_errors)));
4686        let (scan_tx, mut scan_rx) = tokio::sync::mpsc::channel(16);
4687        let scan_token = CancellationToken::new();
4688        let scan_ctx = ConsumerContext::new(scan_tx, scan_token, "file-test-route".to_string());
4689
4690        let scan_result = poll_directory(
4691            &scan_config,
4692            &scan_ctx,
4693            &scan_runtime,
4694            &scan_filters,
4695            &mut scan_seen,
4696            &in_process_locks,
4697            &idempotent_repo,
4698        )
4699        .await;
4700
4701        assert!(scan_result.is_err());
4702        assert!(
4703            scan_errors.lock().unwrap().is_empty(),
4704            "scan failure must not emit b-prime:file:poll-send"
4705        );
4706        assert!(scan_rx.try_recv().is_err());
4707    }
4708}