Skip to main content

camel_component_file/
lib.rs

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