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