Skip to main content

vector/sinks/file/
mod.rs

1use std::{
2    convert::TryFrom,
3    num::NonZeroU64,
4    path::{Path, PathBuf},
5    time::{Duration, Instant},
6};
7
8use async_compression::tokio::write::{GzipEncoder, ZstdEncoder};
9use async_trait::async_trait;
10use bytes::{Bytes, BytesMut};
11use futures::{
12    FutureExt, future,
13    stream::{BoxStream, StreamExt},
14};
15use serde_with::serde_as;
16use tokio::{
17    fs::{self, File},
18    io::AsyncWriteExt,
19};
20use tokio_util::{codec::Encoder as _, time::delay_queue::Expired};
21use vector_lib::{
22    EstimatedJsonEncodedSizeOf, TimeZone,
23    codecs::{
24        TextSerializerConfig,
25        encoding::{Framer, FramingConfig},
26    },
27    configurable::configurable_component,
28    internal_event::{CountByteSize, EventsSent, InternalEventHandle as _, Output, Registered},
29};
30
31use crate::{
32    codecs::{Encoder, EncodingConfigWithFraming, SinkType, Transformer},
33    config::{AcknowledgementsConfig, GenerateConfig, Input, SinkConfig, SinkContext},
34    event::{Event, EventStatus, Finalizable},
35    expiring_hash_map::ExpiringHashMap,
36    internal_events::{
37        FileBytesSent, FileInternalMetricsConfig, FileIoError, FileOpen,
38        FilePathOutsideBaseDirError, TemplateRenderingError,
39    },
40    sinks::util::{
41        StreamSink,
42        path_confinement::{ConfineError, PathConfinement},
43        timezone_to_offset,
44    },
45    template::{ConfinementConfig, Template},
46};
47
48mod bytes_path;
49
50use bytes_path::BytesPath;
51
52/// Configuration for the `file` sink.
53#[serde_as]
54#[configurable_component(sink("file", "Output observability events into files."))]
55#[derive(Clone, Debug)]
56#[serde(deny_unknown_fields)]
57pub struct FileSinkConfig {
58    /// File path to write events to.
59    ///
60    /// Compression format extension must be explicit.
61    #[configurable(metadata(docs::examples = "/tmp/vector-%Y-%m-%d.log"))]
62    #[configurable(metadata(
63        docs::examples = "/tmp/application-{{ application_id }}-%Y-%m-%d.log"
64    ))]
65    #[configurable(metadata(docs::examples = "/tmp/vector-%Y-%m-%d.log.zst"))]
66    #[configurable(metadata(
67        docs::warnings = "Rendered paths are confined to `base_dir` (derived from the literal prefix of `path` when unset). See the `base_dir` option."
68    ))]
69    pub path: Template,
70
71    /// Directory under which all rendered `path` values must resolve.
72    ///
73    /// When `path` contains event-field references (`{{ field }}`), Vector
74    /// confines every rendered path to this directory. If unset, the base
75    /// directory is derived from the literal prefix of `path` (the portion
76    /// before the first `{{` or `%`). Configuration fails if `path`
77    /// references event fields and no non-root base directory can be
78    /// derived.
79    #[configurable(metadata(docs::examples = "/var/log/vector"))]
80    #[serde(default)]
81    pub base_dir: Option<PathBuf>,
82
83    #[serde(flatten)]
84    pub confinement: ConfinementConfig,
85
86    /// The amount of time that a file can be idle and stay open.
87    ///
88    /// After not receiving any events in this amount of time, the file is flushed and closed.
89    #[serde(default = "default_idle_timeout")]
90    #[serde_as(as = "serde_with::DurationSeconds<u64>")]
91    #[serde(rename = "idle_timeout_secs")]
92    #[configurable(metadata(docs::examples = 600))]
93    #[configurable(metadata(docs::human_name = "Idle Timeout"))]
94    pub idle_timeout: Duration,
95
96    #[serde(flatten)]
97    pub encoding: EncodingConfigWithFraming,
98
99    #[configurable(derived)]
100    #[serde(default, skip_serializing_if = "crate::serde::is_default")]
101    pub compression: Compression,
102
103    #[configurable(derived)]
104    #[serde(
105        default,
106        deserialize_with = "crate::serde::bool_or_struct",
107        skip_serializing_if = "crate::serde::is_default"
108    )]
109    pub acknowledgements: AcknowledgementsConfig,
110
111    #[configurable(derived)]
112    #[serde(default)]
113    pub timezone: Option<TimeZone>,
114
115    #[configurable(derived)]
116    #[serde(default)]
117    pub internal_metrics: FileInternalMetricsConfig,
118
119    #[configurable(derived)]
120    #[serde(default)]
121    pub truncate: FileTruncateConfig,
122}
123
124/// Configuration for truncating files.
125#[configurable_component]
126#[derive(Clone, Debug, Default)]
127#[serde(deny_unknown_fields)]
128pub struct FileTruncateConfig {
129    /// If this is set, files will be truncated after being closed for a set amount of seconds.
130    #[serde(default)]
131    pub after_close_time_secs: Option<NonZeroU64>,
132    /// If this is set, files will be truncated after set amount of seconds of no modifications.
133    #[serde(default)]
134    pub after_modified_time_secs: Option<NonZeroU64>,
135    /// If this is set, files will be truncated after set amount of seconds regardless of the state.
136    #[serde(default)]
137    pub after_secs: Option<NonZeroU64>,
138}
139
140impl GenerateConfig for FileSinkConfig {
141    fn generate_config() -> toml::Value {
142        toml::Value::try_from(Self {
143            path: Template::try_from("/tmp/vector-%Y-%m-%d.log").unwrap(),
144            idle_timeout: default_idle_timeout(),
145            encoding: (None::<FramingConfig>, TextSerializerConfig::default()).into(),
146            compression: Default::default(),
147            acknowledgements: Default::default(),
148            timezone: Default::default(),
149            internal_metrics: Default::default(),
150            truncate: Default::default(),
151            base_dir: None,
152            confinement: ConfinementConfig::default(),
153        })
154        .unwrap()
155    }
156}
157
158const fn default_idle_timeout() -> Duration {
159    Duration::from_secs(30)
160}
161
162/// Compression configuration.
163// TODO: Why doesn't this already use `crate::sinks::util::Compression`
164// `crate::sinks::util::Compression` doesn't support zstd yet
165#[configurable_component]
166#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
167#[serde(rename_all = "snake_case")]
168pub enum Compression {
169    /// [Gzip][gzip] compression.
170    ///
171    /// [gzip]: https://www.gzip.org/
172    Gzip,
173
174    /// [Zstandard][zstd] compression.
175    ///
176    /// [zstd]: https://facebook.github.io/zstd/
177    Zstd,
178
179    /// No compression.
180    #[default]
181    None,
182}
183
184struct OutFile {
185    created_at: Instant,
186    inner: OutFileInner,
187}
188
189enum OutFileInner {
190    Regular(File),
191    Gzip(GzipEncoder<File>),
192    Zstd(ZstdEncoder<File>),
193}
194
195impl OutFile {
196    fn new(file: File, compression: Compression) -> Self {
197        Self {
198            created_at: Instant::now(),
199            inner: match compression {
200                Compression::None => OutFileInner::Regular(file),
201                Compression::Gzip => OutFileInner::Gzip(GzipEncoder::new(file)),
202                Compression::Zstd => OutFileInner::Zstd(ZstdEncoder::new(file)),
203            },
204        }
205    }
206
207    async fn sync_all(&mut self) -> Result<(), std::io::Error> {
208        match &mut self.inner {
209            OutFileInner::Regular(file) => file.sync_all().await,
210            OutFileInner::Gzip(gzip) => gzip.get_mut().sync_all().await,
211            OutFileInner::Zstd(zstd) => zstd.get_mut().sync_all().await,
212        }
213    }
214
215    async fn shutdown(&mut self) -> Result<(), std::io::Error> {
216        match &mut self.inner {
217            OutFileInner::Regular(file) => file.shutdown().await,
218            OutFileInner::Gzip(gzip) => gzip.shutdown().await,
219            OutFileInner::Zstd(zstd) => zstd.shutdown().await,
220        }
221    }
222
223    async fn write_all(&mut self, src: &[u8]) -> Result<(), std::io::Error> {
224        match &mut self.inner {
225            OutFileInner::Regular(file) => file.write_all(src).await,
226            OutFileInner::Gzip(gzip) => gzip.write_all(src).await,
227            OutFileInner::Zstd(zstd) => zstd.write_all(src).await,
228        }
229    }
230
231    const fn created_at(&self) -> Instant {
232        self.created_at
233    }
234
235    /// Shutdowns by flushing data, writing headers, and syncing all of that
236    /// data and metadata to the filesystem.
237    async fn close(&mut self) -> Result<(), std::io::Error> {
238        self.shutdown().await?;
239        self.sync_all().await
240    }
241}
242
243#[async_trait::async_trait]
244#[typetag::serde(name = "file")]
245impl SinkConfig for FileSinkConfig {
246    async fn build(
247        &self,
248        cx: SinkContext,
249    ) -> crate::Result<(super::VectorSink, super::Healthcheck)> {
250        let sink = FileSink::new(self, cx)?;
251        self.confinement.set_confinement_gauge("sink", Self::NAME);
252        Ok((
253            super::VectorSink::from_event_streamsink(sink),
254            future::ok(()).boxed(),
255        ))
256    }
257
258    fn input(&self) -> Input {
259        Input::new(self.encoding.config().1.input_type())
260    }
261
262    fn acknowledgements(&self) -> &AcknowledgementsConfig {
263        &self.acknowledgements
264    }
265}
266
267pub struct FileSink {
268    path: Template,
269    transformer: Transformer,
270    encoder: Encoder<Framer>,
271    idle_timeout: Duration,
272    files: ExpiringHashMap<Bytes, OutFile>,
273    compression: Compression,
274    events_sent: Registered<EventsSent>,
275    include_file_metric_tag: bool,
276    truncation_config: FileTruncateConfig,
277    confinement: Option<PathConfinement>,
278}
279
280impl FileSink {
281    pub fn new(config: &FileSinkConfig, cx: SinkContext) -> crate::Result<Self> {
282        let transformer = config.encoding.transformer();
283        let (framer, serializer) = config.encoding.build(SinkType::StreamBased)?;
284        let encoder = Encoder::<Framer>::new(framer, serializer);
285
286        let offset = config
287            .timezone
288            .or(cx.globals.timezone)
289            .and_then(timezone_to_offset);
290
291        // Config validation runs regardless of the opt-out: a relative
292        // `base_dir` is a syntactic error, not a confinement decision.
293        if let Some(base) = config.base_dir.as_ref()
294            && base.is_relative()
295        {
296            return Err(Box::new(
297                crate::sinks::util::path_confinement::BuildError::BaseNotAbsolute {
298                    path: base.clone(),
299                },
300            ));
301        }
302
303        let confinement = if config
304            .confinement
305            .dangerously_allow_unconfined_template_resolution
306        {
307            ConfinementConfig::warn_unconfined_template("sink", "file", "path");
308            None
309        } else {
310            PathConfinement::for_template(&config.path, config.base_dir.as_deref())
311                .map_err(Box::new)?
312        };
313
314        Ok(Self {
315            path: config.path.clone().with_tz_offset(offset),
316            transformer,
317            encoder,
318            idle_timeout: config.idle_timeout,
319            files: ExpiringHashMap::default(),
320            compression: config.compression,
321            events_sent: register!(EventsSent::from(Output(None))),
322            include_file_metric_tag: config.internal_metrics.include_file_tag,
323            truncation_config: config.truncate.clone(),
324            confinement,
325        })
326    }
327
328    /// Uses pass the `event` to `self.path` template to obtain the file path
329    /// to store the event as.
330    fn partition_event(&mut self, event: &Event) -> Option<bytes::Bytes> {
331        let bytes = match self.path.render(event) {
332            Ok(b) => b,
333            Err(error) => {
334                emit!(TemplateRenderingError {
335                    error,
336                    field: Some("path"),
337                    drop_event: true,
338                });
339                return None;
340            }
341        };
342
343        if let Some(confinement) = self.confinement.as_ref() {
344            let rendered_path = bytes_to_path(&bytes);
345            match confinement.confine(&rendered_path) {
346                Ok(normalized) => Some(path_to_bytes(&normalized)),
347                Err(error) => {
348                    emit!(FilePathOutsideBaseDirError {
349                        path: &rendered_path,
350                        base_dir: confinement.base_dir(),
351                        error,
352                    });
353                    None
354                }
355            }
356        } else {
357            Some(bytes)
358        }
359    }
360
361    fn deadline_at(&self) -> Instant {
362        Instant::now()
363            .checked_add(self.idle_timeout)
364            .expect("unable to compute next deadline")
365    }
366
367    async fn run(&mut self, mut input: BoxStream<'_, Event>) -> crate::Result<()> {
368        loop {
369            tokio::select! {
370                event = input.next() => {
371                    match event {
372                        Some(event) => self.process_event(event).await,
373                        None => {
374                            // If we got `None` - terminate the processing.
375                            debug!(message = "Receiver exhausted, terminating the processing loop.");
376
377                            // Close all the open files.
378                            debug!(message = "Closing all the open files.");
379                            for (path, file) in self.files.iter_mut() {
380                                if let Err(error) = file.close().await {
381                                    emit!(FileIoError {
382                                        error,
383                                        code: "failed_closing_file",
384                                        message: "Failed to close file.",
385                                        path,
386                                        dropped_events: 0,
387                                    });
388                                } else{
389                                    trace!(message = "Successfully closed file.", path = ?path);
390                                }
391                            }
392
393                            emit!(FileOpen {
394                                count: 0
395                            });
396
397                            break;
398                        }
399                    }
400                }
401                result = self.files.next_expired(), if !self.files.is_empty() => {
402                    match result {
403                        // We do not poll map when it's empty, so we should
404                        // never reach this branch.
405                        None => unreachable!(),
406                        Some((expired_file, path)) => {
407                            // We got an expired file. All we really want is to
408                            // flush and close it.
409                            self.close_file(expired_file, path).await;
410                        }
411                    }
412                }
413            }
414        }
415
416        Ok(())
417    }
418
419    async fn process_event(&mut self, mut event: Event) {
420        let path = match self.partition_event(&event) {
421            Some(path) => path,
422            None => {
423                // We weren't able to find the path to use for the
424                // file.
425                // The error is already handled at `partition_event`, so
426                // here we just skip the event.
427                event.metadata().update_status(EventStatus::Errored);
428                return;
429            }
430        };
431
432        let next_deadline = self.deadline_at();
433        trace!(message = "Computed next deadline.", next_deadline = ?next_deadline, path = ?path);
434
435        let bytes_path = BytesPath::new(path.clone());
436        let truncate = self.should_truncate(&bytes_path, &path).await;
437        let file = if !truncate && let Some(file) = self.files.reset_at(&path, next_deadline) {
438            trace!(message = "Working with an already opened file.", path = ?path);
439            file
440        } else {
441            trace!(message = "Opening new file.", ?path);
442            let file = match open_file(bytes_path, truncate, self.confinement.as_mut()).await {
443                Ok(file) => file,
444                Err(OpenError::Io(error)) => {
445                    // We couldn't open the file for this event.
446                    // Maybe other events will work though! Just log
447                    // the error and skip this event.
448                    emit!(FileIoError {
449                        code: "failed_opening_file",
450                        message: "Unable to open the file.",
451                        error,
452                        path: &path,
453                        dropped_events: 1,
454                    });
455                    event.metadata().update_status(EventStatus::Errored);
456                    return;
457                }
458                Err(OpenError::Confine(error)) => {
459                    let rendered = bytes_to_path(&path);
460                    let base = self
461                        .confinement
462                        .as_ref()
463                        .map(|c| c.base_dir().to_path_buf())
464                        .unwrap_or_default();
465                    emit!(FilePathOutsideBaseDirError {
466                        path: &rendered,
467                        base_dir: &base,
468                        error,
469                    });
470                    event.metadata().update_status(EventStatus::Errored);
471                    return;
472                }
473            };
474
475            let outfile = OutFile::new(file, self.compression);
476
477            self.files.insert_at(path.clone(), outfile, next_deadline);
478            emit!(FileOpen {
479                count: self.files.len()
480            });
481            self.files.get_mut(&path).unwrap()
482        };
483
484        trace!(message = "Writing an event to file.", path = ?path);
485        let event_size = event.estimated_json_encoded_size_of();
486        let finalizers = event.take_finalizers();
487        match write_event_to_file(file, event, &self.transformer, &mut self.encoder).await {
488            Ok(byte_size) => {
489                finalizers.update_status(EventStatus::Delivered);
490                self.events_sent.emit(CountByteSize(1, event_size));
491                emit!(FileBytesSent {
492                    byte_size,
493                    file: String::from_utf8_lossy(&path),
494                    include_file_metric_tag: self.include_file_metric_tag,
495                });
496            }
497            Err(error) => {
498                finalizers.update_status(EventStatus::Errored);
499                emit!(FileIoError {
500                    code: "failed_writing_file",
501                    message: "Failed to write the file.",
502                    error,
503                    path: &path,
504                    dropped_events: 1,
505                });
506            }
507        }
508    }
509
510    async fn should_truncate(&mut self, bytes_path: &BytesPath, path: &bytes::Bytes) -> bool {
511        let mut truncate = false;
512
513        if let Some(after_close_time_secs) = self.truncation_config.after_close_time_secs
514            && self.files.get(path).is_none()
515            && let Ok(metadata) = fs::metadata(bytes_path).await
516            && let Ok(time) = metadata
517                .modified()
518                .map_err(|_| ())
519                .and_then(|t| t.elapsed().map_err(|_| ()))
520            && time.as_secs() > after_close_time_secs.into()
521        {
522            truncate = true;
523        }
524
525        if let Some(after_secs) = self.truncation_config.after_secs
526            && let Some(file) = self.files.get(path)
527            && (file.created_at().elapsed().as_secs() > after_secs.into())
528        {
529            truncate = true;
530        }
531
532        if let Some(after_modified_time_secs) = self.truncation_config.after_modified_time_secs
533            && let Some(previous_modification) = self
534                .files
535                .get_with_deadline(path)
536                .and_then(|(_, deadline)| deadline.checked_sub(self.idle_timeout))
537            && previous_modification.elapsed().as_secs() > after_modified_time_secs.into()
538        {
539            truncate = true;
540        }
541
542        if truncate && let Some((file, path)) = self.files.remove(path) {
543            self.close_file(file, path).await;
544        }
545
546        truncate
547    }
548
549    async fn close_file(&self, mut file: OutFile, path: Expired<Bytes>) {
550        if let Err(error) = file.close().await {
551            emit!(FileIoError {
552                error,
553                code: "failed_closing_file",
554                message: "Failed to close file.",
555                path: &path,
556                dropped_events: 0,
557            });
558        }
559        drop(file); // ignore close error
560        emit!(FileOpen {
561            count: self.files.len()
562        });
563    }
564}
565
566#[cfg(unix)]
567fn bytes_to_path(b: &Bytes) -> PathBuf {
568    use std::os::unix::ffi::OsStrExt;
569    PathBuf::from(std::ffi::OsStr::from_bytes(b))
570}
571
572#[cfg(not(unix))]
573fn bytes_to_path(b: &Bytes) -> PathBuf {
574    PathBuf::from(String::from_utf8_lossy(b).as_ref())
575}
576
577#[cfg(unix)]
578fn path_to_bytes(p: &Path) -> Bytes {
579    use std::os::unix::ffi::OsStrExt;
580    Bytes::copy_from_slice(p.as_os_str().as_bytes())
581}
582
583#[cfg(not(unix))]
584fn path_to_bytes(p: &Path) -> Bytes {
585    Bytes::from(p.to_string_lossy().into_owned().into_bytes())
586}
587
588/// Errors produced by `open_file`. Routed at the call site so that
589/// confinement failures emit `FilePathOutsideBaseDirError` (INTENTIONAL drop)
590/// instead of the generic `FileIoError` (UNINTENTIONAL).
591#[derive(Debug)]
592enum OpenError {
593    Io(std::io::Error),
594    Confine(ConfineError),
595}
596
597impl std::fmt::Display for OpenError {
598    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
599        match self {
600            Self::Io(e) => write!(f, "{e}"),
601            Self::Confine(e) => write!(f, "{e}"),
602        }
603    }
604}
605
606impl std::error::Error for OpenError {}
607
608/// Create `path` and all missing ancestors, refusing to follow any symlink
609/// in the components that fall *below* `base`.
610///
611/// The `base` directory is operator-authored and trusted — it is created with
612/// the standard `create_dir_all` (which follows symlinks), so paths like
613/// `/tmp/myapp` work correctly on macOS where `/tmp → /private/tmp`.
614/// Only the suffix of `path` below `base` — the event-controlled part —
615/// is walked component-by-component with `lstat` checks.
616///
617/// A residual TOCTOU window exists between the `symlink_metadata` check and
618/// the `create_dir` call. Closing it requires fd-based traversal (`cap-std`),
619/// which is Phase 1b scope. `verify_parent` provides a second layer of
620/// defence after this call.
621#[cfg(unix)]
622async fn create_dirs_nofollow(path: &Path, base: &Path) -> std::io::Result<()> {
623    fs::create_dir_all(base).await?;
624    let suffix = path.strip_prefix(base).unwrap_or(path);
625    let mut current = base.to_path_buf();
626    for component in suffix.components() {
627        current.push(component);
628        match fs::symlink_metadata(&current).await {
629            Ok(meta) if meta.file_type().is_symlink() => {
630                return Err(std::io::Error::other(format!(
631                    "intermediate path component {:?} is a symlink",
632                    current
633                )));
634            }
635            Ok(_) => {}
636            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
637                match fs::create_dir(&current).await {
638                    Ok(()) => {}
639                    Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
640                    Err(e) => return Err(e),
641                }
642            }
643            Err(e) => return Err(e),
644        }
645    }
646    Ok(())
647}
648
649async fn open_file(
650    path: impl AsRef<Path>,
651    truncate: bool,
652    confinement: Option<&mut PathConfinement>,
653) -> Result<File, OpenError> {
654    let path_ref = path.as_ref();
655    let parent = path_ref.parent();
656    let file_name = path_ref.file_name();
657
658    let confined = confinement.is_some();
659    // Extract the base before `confinement` is moved into the open_path match.
660    #[cfg(unix)]
661    let base_dir = confinement.as_ref().map(|c| c.base_dir().to_path_buf());
662
663    if let Some(parent) = parent {
664        // When confined, refuse to follow intermediate symlinks in the
665        // event-controlled portion of the path. On non-Unix platforms we fall
666        // back to the standard `create_dir_all` (Windows reparse-point
667        // protection is Phase 1b scope).
668        #[cfg(unix)]
669        if let Some(ref base) = base_dir {
670            create_dirs_nofollow(parent, base)
671                .await
672                .map_err(OpenError::Io)?;
673        } else {
674            fs::create_dir_all(parent).await.map_err(OpenError::Io)?;
675        }
676        #[cfg(not(unix))]
677        fs::create_dir_all(parent).await.map_err(OpenError::Io)?;
678    }
679
680    // If confined, verify the parent canonicalizes within the base, and
681    // open relative to the canonicalized parent. This catches symlinks on
682    // any intermediate directory.
683    let open_path: PathBuf = match (confinement, parent, file_name) {
684        (Some(confinement), Some(parent), Some(file_name)) => {
685            let canonical_parent = confinement
686                .verify_parent(parent)
687                .await
688                .map_err(OpenError::Confine)?;
689            canonical_parent.join(file_name)
690        }
691        _ => path_ref.to_path_buf(),
692    };
693
694    let mut opts = fs::OpenOptions::new();
695    opts.read(false)
696        .write(true)
697        .create(true)
698        .append(!truncate)
699        .truncate(truncate);
700
701    // Reject final-component symlinks when confined. Do NOT apply
702    // O_NOFOLLOW to unconfined static paths — operators who intentionally
703    // symlink their log file (outside the threat model) must keep working.
704    #[cfg(unix)]
705    if confined {
706        opts.custom_flags(libc::O_NOFOLLOW);
707    }
708    #[cfg(not(unix))]
709    let _ = confined;
710
711    opts.open(open_path).await.map_err(OpenError::Io)
712}
713
714async fn write_event_to_file(
715    file: &mut OutFile,
716    mut event: Event,
717    transformer: &Transformer,
718    encoder: &mut Encoder<Framer>,
719) -> Result<usize, std::io::Error> {
720    transformer.transform(&mut event);
721    let mut buffer = BytesMut::new();
722    encoder
723        .encode(event, &mut buffer)
724        .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
725    file.write_all(&buffer).await.map(|()| buffer.len())
726}
727
728#[async_trait]
729impl StreamSink<Event> for FileSink {
730    async fn run(mut self: Box<Self>, input: BoxStream<'_, Event>) -> Result<(), ()> {
731        FileSink::run(&mut self, input)
732            .await
733            .expect("file sink error");
734        Ok(())
735    }
736}
737
738#[cfg(test)]
739mod tests {
740    use std::convert::TryInto;
741
742    use chrono::{SubsecRound, Utc};
743    use futures::{SinkExt, stream};
744    use similar_asserts::assert_eq;
745    use vector_lib::{
746        codecs::JsonSerializerConfig,
747        event::{LogEvent, TraceEvent},
748        sink::VectorSink,
749    };
750    use vrl::event_path;
751
752    use super::*;
753    use crate::{
754        config::log_schema,
755        test_util::{
756            components::{FILE_SINK_TAGS, assert_sink_compliance},
757            lines_from_file, lines_from_gzip_file, lines_from_zstd_file, random_events_with_stream,
758            random_lines_with_stream, random_metrics_with_stream,
759            random_metrics_with_stream_timestamp, temp_dir, temp_file, trace_init,
760        },
761    };
762
763    #[test]
764    fn generate_config() {
765        crate::test_util::test_generate_config::<FileSinkConfig>();
766    }
767
768    #[tokio::test]
769    async fn log_single_partition() {
770        let template = temp_file();
771
772        let config = FileSinkConfig {
773            path: template.clone().try_into().unwrap(),
774            idle_timeout: default_idle_timeout(),
775            encoding: (None::<FramingConfig>, TextSerializerConfig::default()).into(),
776            compression: Compression::None,
777            acknowledgements: Default::default(),
778            timezone: Default::default(),
779            internal_metrics: FileInternalMetricsConfig {
780                include_file_tag: true,
781            },
782            truncate: Default::default(),
783            base_dir: None,
784            confinement: ConfinementConfig::default(),
785        };
786
787        let (input, _events) = random_lines_with_stream(100, 64, None);
788
789        run_assert_log_sink(&config, input.clone()).await;
790
791        let output = lines_from_file(template);
792        for (input, output) in input.into_iter().zip(output) {
793            assert_eq!(input, output);
794        }
795    }
796
797    #[tokio::test]
798    async fn log_single_partition_gzip() {
799        let template = temp_file();
800
801        let config = FileSinkConfig {
802            path: template.clone().try_into().unwrap(),
803            idle_timeout: default_idle_timeout(),
804            encoding: (None::<FramingConfig>, TextSerializerConfig::default()).into(),
805            compression: Compression::Gzip,
806            acknowledgements: Default::default(),
807            timezone: Default::default(),
808            internal_metrics: FileInternalMetricsConfig {
809                include_file_tag: true,
810            },
811            truncate: Default::default(),
812            base_dir: None,
813            confinement: ConfinementConfig::default(),
814        };
815
816        let (input, _) = random_lines_with_stream(100, 64, None);
817
818        run_assert_log_sink(&config, input.clone()).await;
819
820        let output = lines_from_gzip_file(template);
821        for (input, output) in input.into_iter().zip(output) {
822            assert_eq!(input, output);
823        }
824    }
825
826    #[tokio::test]
827    async fn log_single_partition_zstd() {
828        let template = temp_file();
829
830        let config = FileSinkConfig {
831            path: template.clone().try_into().unwrap(),
832            idle_timeout: default_idle_timeout(),
833            encoding: (None::<FramingConfig>, TextSerializerConfig::default()).into(),
834            compression: Compression::Zstd,
835            acknowledgements: Default::default(),
836            timezone: Default::default(),
837            internal_metrics: FileInternalMetricsConfig {
838                include_file_tag: true,
839            },
840            truncate: Default::default(),
841            base_dir: None,
842            confinement: ConfinementConfig::default(),
843        };
844
845        let (input, _) = random_lines_with_stream(100, 64, None);
846
847        run_assert_log_sink(&config, input.clone()).await;
848
849        let output = lines_from_zstd_file(template);
850        for (input, output) in input.into_iter().zip(output) {
851            assert_eq!(input, output);
852        }
853    }
854
855    #[tokio::test]
856    async fn log_many_partitions() {
857        let directory = temp_dir();
858
859        let mut template = directory.to_string_lossy().to_string();
860        template.push_str("/{{level}}s-{{date}}.log");
861
862        trace!(message = "Template.", %template);
863
864        let config = FileSinkConfig {
865            path: template.try_into().unwrap(),
866            idle_timeout: default_idle_timeout(),
867            encoding: (None::<FramingConfig>, TextSerializerConfig::default()).into(),
868            compression: Compression::None,
869            acknowledgements: Default::default(),
870            timezone: Default::default(),
871            internal_metrics: FileInternalMetricsConfig {
872                include_file_tag: true,
873            },
874            truncate: Default::default(),
875            base_dir: None,
876            confinement: ConfinementConfig::default(),
877        };
878
879        let (mut input, _events) = random_events_with_stream(32, 8, None);
880        input[0]
881            .as_mut_log()
882            .insert(event_path!("date"), "2019-26-07");
883        input[0]
884            .as_mut_log()
885            .insert(event_path!("level"), "warning");
886        input[1]
887            .as_mut_log()
888            .insert(event_path!("date"), "2019-26-07");
889        input[1].as_mut_log().insert(event_path!("level"), "error");
890        input[2]
891            .as_mut_log()
892            .insert(event_path!("date"), "2019-26-07");
893        input[2]
894            .as_mut_log()
895            .insert(event_path!("level"), "warning");
896        input[3]
897            .as_mut_log()
898            .insert(event_path!("date"), "2019-27-07");
899        input[3].as_mut_log().insert(event_path!("level"), "error");
900        input[4]
901            .as_mut_log()
902            .insert(event_path!("date"), "2019-27-07");
903        input[4]
904            .as_mut_log()
905            .insert(event_path!("level"), "warning");
906        input[5]
907            .as_mut_log()
908            .insert(event_path!("date"), "2019-27-07");
909        input[5]
910            .as_mut_log()
911            .insert(event_path!("level"), "warning");
912        input[6]
913            .as_mut_log()
914            .insert(event_path!("date"), "2019-28-07");
915        input[6]
916            .as_mut_log()
917            .insert(event_path!("level"), "warning");
918        input[7]
919            .as_mut_log()
920            .insert(event_path!("date"), "2019-29-07");
921        input[7].as_mut_log().insert(event_path!("level"), "error");
922
923        run_assert_sink(&config, input.clone().into_iter()).await;
924
925        let output = [
926            lines_from_file(directory.join("warnings-2019-26-07.log")),
927            lines_from_file(directory.join("errors-2019-26-07.log")),
928            lines_from_file(directory.join("warnings-2019-27-07.log")),
929            lines_from_file(directory.join("errors-2019-27-07.log")),
930            lines_from_file(directory.join("warnings-2019-28-07.log")),
931            lines_from_file(directory.join("errors-2019-29-07.log")),
932        ];
933
934        let message_key = log_schema().message_key().unwrap().to_string();
935        assert_eq!(
936            input[0].as_log()[&message_key],
937            From::<&str>::from(&output[0][0])
938        );
939        assert_eq!(
940            input[1].as_log()[&message_key],
941            From::<&str>::from(&output[1][0])
942        );
943        assert_eq!(
944            input[2].as_log()[&message_key],
945            From::<&str>::from(&output[0][1])
946        );
947        assert_eq!(
948            input[3].as_log()[&message_key],
949            From::<&str>::from(&output[3][0])
950        );
951        assert_eq!(
952            input[4].as_log()[&message_key],
953            From::<&str>::from(&output[2][0])
954        );
955        assert_eq!(
956            input[5].as_log()[&message_key],
957            From::<&str>::from(&output[2][1])
958        );
959        assert_eq!(
960            input[6].as_log()[&message_key],
961            From::<&str>::from(&output[4][0])
962        );
963        assert_eq!(
964            input[7].as_log()[message_key],
965            From::<&str>::from(&output[5][0])
966        );
967    }
968
969    #[tokio::test]
970    async fn log_reopening() {
971        trace_init();
972
973        let template = temp_file();
974
975        let config = FileSinkConfig {
976            path: template.clone().try_into().unwrap(),
977            idle_timeout: Duration::from_secs(1),
978            encoding: (None::<FramingConfig>, TextSerializerConfig::default()).into(),
979            compression: Compression::None,
980            acknowledgements: Default::default(),
981            timezone: Default::default(),
982            internal_metrics: FileInternalMetricsConfig {
983                include_file_tag: true,
984            },
985            truncate: Default::default(),
986            base_dir: None,
987            confinement: ConfinementConfig::default(),
988        };
989
990        let (mut input, _events) = random_lines_with_stream(10, 64, None);
991
992        let (mut tx, rx) = futures::channel::mpsc::channel(0);
993
994        let sink_handle = tokio::spawn(async move {
995            assert_sink_compliance(&FILE_SINK_TAGS, async move {
996                let sink = FileSink::new(&config, SinkContext::default()).unwrap();
997                VectorSink::from_event_streamsink(sink)
998                    .run(Box::pin(rx.map(Into::into)))
999                    .await
1000                    .expect("Running sink failed");
1001            })
1002            .await
1003        });
1004
1005        // send initial payload
1006        for line in input.clone() {
1007            tx.send(Event::Log(LogEvent::from(line))).await.unwrap();
1008        }
1009
1010        // wait for file to go idle and be closed
1011        tokio::time::sleep(Duration::from_secs(2)).await;
1012
1013        // trigger another write
1014        let last_line = "i should go at the end";
1015        tx.send(LogEvent::from(last_line).into()).await.unwrap();
1016        input.push(String::from(last_line));
1017
1018        // wait for another flush
1019        tokio::time::sleep(Duration::from_secs(1)).await;
1020
1021        // make sure we appended instead of overwriting
1022        let output = lines_from_file(template);
1023        assert_eq!(input, output);
1024
1025        // make sure sink stops and that it did not panic
1026        drop(tx);
1027        sink_handle.await.unwrap();
1028    }
1029
1030    #[tokio::test]
1031    async fn metric_single_partition() {
1032        let template = temp_file();
1033
1034        let config = FileSinkConfig {
1035            path: template.clone().try_into().unwrap(),
1036            idle_timeout: default_idle_timeout(),
1037            encoding: (None::<FramingConfig>, TextSerializerConfig::default()).into(),
1038            compression: Compression::None,
1039            acknowledgements: Default::default(),
1040            timezone: Default::default(),
1041            internal_metrics: FileInternalMetricsConfig {
1042                include_file_tag: true,
1043            },
1044            truncate: Default::default(),
1045            base_dir: None,
1046            confinement: ConfinementConfig::default(),
1047        };
1048
1049        let (input, _events) = random_metrics_with_stream(100, None, None);
1050
1051        run_assert_sink(&config, input.clone().into_iter()).await;
1052
1053        let output = lines_from_file(template);
1054        for (input, output) in input.into_iter().zip(output) {
1055            let metric_name = input.as_metric().name();
1056            assert!(output.contains(metric_name));
1057        }
1058    }
1059
1060    #[tokio::test]
1061    async fn metric_many_partitions() {
1062        let directory = temp_dir();
1063
1064        let format = "%Y-%m-%d-%H-%M-%S";
1065        let mut template = directory.to_string_lossy().to_string();
1066        template.push_str(&format!("/{format}.log"));
1067
1068        let config = FileSinkConfig {
1069            path: template.try_into().unwrap(),
1070            idle_timeout: default_idle_timeout(),
1071            encoding: (None::<FramingConfig>, TextSerializerConfig::default()).into(),
1072            compression: Compression::None,
1073            acknowledgements: Default::default(),
1074            timezone: Default::default(),
1075            internal_metrics: FileInternalMetricsConfig {
1076                include_file_tag: true,
1077            },
1078            truncate: Default::default(),
1079            base_dir: None,
1080            confinement: ConfinementConfig::default(),
1081        };
1082
1083        let metric_count = 3;
1084        let timestamp = Utc::now().trunc_subsecs(3);
1085        let timestamp_offset = Duration::from_secs(1);
1086
1087        let (input, _events) = random_metrics_with_stream_timestamp(
1088            metric_count,
1089            None,
1090            None,
1091            timestamp,
1092            timestamp_offset,
1093        );
1094
1095        run_assert_sink(&config, input.clone().into_iter()).await;
1096
1097        let output = (0..metric_count).map(|index| {
1098            let expected_timestamp = timestamp + (timestamp_offset * index as u32);
1099            let expected_filename =
1100                directory.join(format!("{}.log", expected_timestamp.format(format)));
1101
1102            lines_from_file(expected_filename)
1103        });
1104        for (input, output) in input.iter().zip(output) {
1105            // The format will partition by second and metrics are a second apart.
1106            assert_eq!(
1107                output.len(),
1108                1,
1109                "Expected the output file to contain one metric"
1110            );
1111            let output = &output[0];
1112
1113            let metric_name = input.as_metric().name();
1114            assert!(output.contains(metric_name));
1115        }
1116    }
1117
1118    #[tokio::test]
1119    async fn trace_single_partition() {
1120        let template = temp_file();
1121
1122        let config = FileSinkConfig {
1123            path: template.clone().try_into().unwrap(),
1124            idle_timeout: default_idle_timeout(),
1125            encoding: (None::<FramingConfig>, JsonSerializerConfig::default()).into(),
1126            compression: Compression::None,
1127            acknowledgements: Default::default(),
1128            timezone: Default::default(),
1129            internal_metrics: FileInternalMetricsConfig {
1130                include_file_tag: true,
1131            },
1132            truncate: Default::default(),
1133            base_dir: None,
1134            confinement: ConfinementConfig::default(),
1135        };
1136
1137        let (input, _events) = random_lines_with_stream(100, 64, None);
1138
1139        run_assert_trace_sink(&config, input.clone()).await;
1140
1141        let output = lines_from_file(template);
1142        for (input, output) in input.iter().zip(output) {
1143            assert!(output.contains(input));
1144        }
1145    }
1146
1147    fn base_config(path: &str) -> FileSinkConfig {
1148        FileSinkConfig {
1149            path: path.try_into().unwrap(),
1150            idle_timeout: default_idle_timeout(),
1151            encoding: (None::<FramingConfig>, TextSerializerConfig::default()).into(),
1152            compression: Compression::None,
1153            acknowledgements: Default::default(),
1154            timezone: Default::default(),
1155            internal_metrics: Default::default(),
1156            truncate: Default::default(),
1157            base_dir: None,
1158            confinement: ConfinementConfig::default(),
1159        }
1160    }
1161
1162    // Uses Unix-shaped `/` absolute paths in test fixtures. On Windows those
1163    // strings aren't recognised as absolute and the build-error taxonomy
1164    // shifts (NoDerivableBase vs DerivedBaseIsRoot).
1165    #[cfg(unix)]
1166    #[test]
1167    fn sink_build_cases() {
1168        enum Expected {
1169            NoConfinement,
1170            Confined,
1171            ErrContaining(&'static str),
1172        }
1173        use Expected::*;
1174        let dir = temp_dir();
1175        let dynamic = format!("{}/{{{{ key }}}}.log", dir.display());
1176        let cases: &[(&str, Option<PathBuf>, bool, Expected)] = &[
1177            // static path → no confinement
1178            ("/tmp/static.log", None, false, NoConfinement),
1179            // dynamic path → confinement auto-derived
1180            (&dynamic, None, false, Confined),
1181            // no derivable base → error
1182            (
1183                "{{ key }}",
1184                None,
1185                false,
1186                ErrContaining("no literal directory prefix"),
1187            ),
1188            // derived base is root → error
1189            (
1190                "/{{ x }}/a.log",
1191                None,
1192                false,
1193                ErrContaining("filesystem root"),
1194            ),
1195            // explicit root base_dir is allowed (operator opt-in)
1196            ("/{{ x }}", Some(PathBuf::from("/")), false, Confined),
1197            // escape hatch suppresses NoDerivableBase
1198            ("{{ key }}", None, true, NoConfinement),
1199        ];
1200        for (path, base_dir, hatch, expected) in cases {
1201            let mut cfg = base_config(path);
1202            cfg.base_dir = base_dir.clone();
1203            cfg.confinement
1204                .dangerously_allow_unconfined_template_resolution = *hatch;
1205            let result = FileSink::new(&cfg, SinkContext::default());
1206            match expected {
1207                NoConfinement => assert!(result.unwrap().confinement.is_none(), "path={path:?}"),
1208                Confined => assert!(result.unwrap().confinement.is_some(), "path={path:?}"),
1209                ErrContaining(msg) => {
1210                    let err = match result {
1211                        Err(e) => e,
1212                        Ok(_) => panic!("expected build error for path={path:?}"),
1213                    };
1214                    assert!(err.to_string().contains(msg), "path={path:?} err={err}");
1215                }
1216            }
1217        }
1218    }
1219
1220    #[tokio::test]
1221    async fn confine_drops_dotdot_traversal() {
1222        // PoC payload: tenant field carries `../..` to escape the base dir.
1223        let dir = temp_dir();
1224        let path = format!("{}/apps/{{{{ service }}}}/app.log", dir.display());
1225        let cfg = base_config(&path);
1226
1227        let mut event = Event::Log(LogEvent::from("payload"));
1228        event
1229            .as_mut_log()
1230            .insert(event_path!("service"), "../../../etc/cron.d/vh-poc");
1231
1232        let mut sink = FileSink::new(&cfg, SinkContext::default()).unwrap();
1233        assert!(sink.partition_event(&event).is_none());
1234    }
1235
1236    #[tokio::test]
1237    async fn confine_collapses_absolute_injection_into_base() {
1238        // When a field value begins with `/`, the template render produces
1239        // `<base>//<value>` which lexically collapses to `<base>/<value>`.
1240        // The leading slash is harmless (a separator, not an escape) — the
1241        // event is still confined to the base.
1242        let dir = temp_dir();
1243        let path = format!("{}/{{{{ key }}}}.log", dir.display());
1244        let cfg = base_config(&path);
1245
1246        let mut event = Event::Log(LogEvent::from("payload"));
1247        event.as_mut_log().insert(event_path!("key"), "/etc/passwd");
1248
1249        let mut sink = FileSink::new(&cfg, SinkContext::default()).unwrap();
1250        let confined = sink.partition_event(&event).unwrap();
1251        let confined_str = String::from_utf8_lossy(&confined);
1252        assert!(
1253            confined_str.starts_with(&*dir.to_string_lossy()),
1254            "expected {confined_str} to remain under {}",
1255            dir.display()
1256        );
1257    }
1258
1259    // The path template embeds a literal `/` before the field, which is
1260    // Unix-shaped: on Windows the rendered separator flips to `\`.
1261    #[cfg(unix)]
1262    #[tokio::test]
1263    async fn confine_allows_legit_partition() {
1264        let dir = temp_dir();
1265        let path = format!("{}/{{{{ key }}}}.log", dir.display());
1266        let cfg = base_config(&path);
1267
1268        let mut event = Event::Log(LogEvent::from("payload"));
1269        event.as_mut_log().insert(event_path!("key"), "tenant-a");
1270
1271        let mut sink = FileSink::new(&cfg, SinkContext::default()).unwrap();
1272        let rendered = sink.partition_event(&event).unwrap();
1273        let rendered_str = String::from_utf8_lossy(&rendered);
1274        assert!(rendered_str.ends_with("/tenant-a.log"), "{rendered_str}");
1275    }
1276
1277    #[test]
1278    fn escape_hatch_suppresses_build_error() {
1279        // No base derivable, but the flag is set → build succeeds with
1280        // confinement disabled.
1281        let mut cfg = base_config("{{ key }}");
1282        cfg.confinement
1283            .dangerously_allow_unconfined_template_resolution = true;
1284        let sink = FileSink::new(&cfg, SinkContext::default()).unwrap();
1285        assert!(sink.confinement.is_none());
1286    }
1287
1288    #[tokio::test]
1289    async fn escape_hatch_bypasses_confinement_even_when_base_derivable() {
1290        // With the flag set, confinement is fully disabled — even when a base
1291        // would otherwise be derivable. The flag is a complete opt-out.
1292        let dir = temp_dir();
1293        let path = format!("{}/{{{{ key }}}}.log", dir.display());
1294        let mut cfg = base_config(&path);
1295        cfg.confinement
1296            .dangerously_allow_unconfined_template_resolution = true;
1297
1298        let mut sink = FileSink::new(&cfg, SinkContext::default()).unwrap();
1299        assert!(sink.confinement.is_none());
1300
1301        let mut event = Event::Log(LogEvent::from("payload"));
1302        event.as_mut_log().insert(event_path!("key"), "safe-value");
1303        // Event routes through — no confinement check.
1304        assert!(sink.partition_event(&event).is_some());
1305    }
1306
1307    #[tokio::test]
1308    async fn vector_validate_no_fs_io() {
1309        // base_dir need not exist for FileSink::new to succeed:
1310        // we defer FS I/O until the first event, so `vector validate` works
1311        // on volumes that aren't mounted yet.
1312        let dir = temp_dir();
1313        let path = format!("{}/{{{{ key }}}}.log", dir.display());
1314        let mut cfg = base_config(&path);
1315        cfg.base_dir = Some(dir.join("does-not-yet-exist"));
1316        // No filesystem precondition is established.
1317        let _ = FileSink::new(&cfg, SinkContext::default()).unwrap();
1318    }
1319
1320    async fn run_assert_log_sink(config: &FileSinkConfig, events: Vec<String>) {
1321        run_assert_sink(
1322            config,
1323            events.into_iter().map(LogEvent::from).map(Event::Log),
1324        )
1325        .await;
1326    }
1327
1328    async fn run_assert_trace_sink(config: &FileSinkConfig, events: Vec<String>) {
1329        run_assert_sink(
1330            config,
1331            events
1332                .into_iter()
1333                .map(LogEvent::from)
1334                .map(TraceEvent::from)
1335                .map(Event::Trace),
1336        )
1337        .await;
1338    }
1339
1340    async fn run_assert_sink(config: &FileSinkConfig, events: impl Iterator<Item = Event> + Send) {
1341        assert_sink_compliance(&FILE_SINK_TAGS, async move {
1342            let sink = FileSink::new(config, SinkContext::default()).unwrap();
1343            VectorSink::from_event_streamsink(sink)
1344                .run(Box::pin(stream::iter(events.map(Into::into))))
1345                .await
1346                .expect("Running sink failed")
1347        })
1348        .await;
1349    }
1350
1351    #[cfg(unix)]
1352    #[tokio::test]
1353    async fn create_dirs_nofollow_rejects_intermediate_symlink() {
1354        use tempfile::tempdir;
1355        let tmp = tempdir().unwrap();
1356        let outside = tmp.path().join("outside");
1357        tokio::fs::create_dir(&outside).await.unwrap();
1358
1359        let base = tmp.path().join("base");
1360        tokio::fs::create_dir(&base).await.unwrap();
1361        let link = base.join("link");
1362        tokio::fs::symlink(&outside, &link).await.unwrap();
1363
1364        let result = create_dirs_nofollow(&link.join("newdir"), &base).await;
1365        assert!(result.is_err(), "expected error, got {result:?}");
1366
1367        let mut rd = tokio::fs::read_dir(&outside).await.unwrap();
1368        assert!(
1369            rd.next_entry().await.unwrap().is_none(),
1370            "outside was mutated"
1371        );
1372    }
1373
1374    #[cfg(unix)]
1375    #[tokio::test]
1376    async fn create_dirs_nofollow_allows_system_symlinks_above_base() {
1377        use tempfile::tempdir;
1378        let tmp = tempdir().unwrap();
1379        let real_dir = tmp.path().join("real");
1380        tokio::fs::create_dir(&real_dir).await.unwrap();
1381        let sym_dir = tmp.path().join("sym");
1382        tokio::fs::symlink(&real_dir, &sym_dir).await.unwrap();
1383
1384        // base sits under a symlink (like /tmp on macOS)
1385        let base = sym_dir.join("base");
1386        let path = base.join("sub");
1387
1388        let result = create_dirs_nofollow(&path, &base).await;
1389        assert!(
1390            result.is_ok(),
1391            "should succeed through system symlink: {result:?}"
1392        );
1393    }
1394}