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