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#[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 #[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 #[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 #[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#[configurable_component]
126#[derive(Clone, Debug, Default)]
127#[serde(deny_unknown_fields)]
128pub struct FileTruncateConfig {
129 #[serde(default)]
131 pub after_close_time_secs: Option<NonZeroU64>,
132 #[serde(default)]
134 pub after_modified_time_secs: Option<NonZeroU64>,
135 #[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#[configurable_component]
166#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
167#[serde(rename_all = "snake_case")]
168pub enum Compression {
169 Gzip,
173
174 Zstd,
178
179 #[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 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 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 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 debug!(message = "Receiver exhausted, terminating the processing loop.");
376
377 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 None => unreachable!(),
406 Some((expired_file, path)) => {
407 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 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 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); 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#[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#[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(¤t).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(¤t).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 #[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 #[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 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 #[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 for line in input.clone() {
1007 tx.send(Event::Log(LogEvent::from(line))).await.unwrap();
1008 }
1009
1010 tokio::time::sleep(Duration::from_secs(2)).await;
1012
1013 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 tokio::time::sleep(Duration::from_secs(1)).await;
1020
1021 let output = lines_from_file(template);
1023 assert_eq!(input, output);
1024
1025 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 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 #[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 ("/tmp/static.log", None, false, NoConfinement),
1179 (&dynamic, None, false, Confined),
1181 (
1183 "{{ key }}",
1184 None,
1185 false,
1186 ErrContaining("no literal directory prefix"),
1187 ),
1188 (
1190 "/{{ x }}/a.log",
1191 None,
1192 false,
1193 ErrContaining("filesystem root"),
1194 ),
1195 ("/{{ x }}", Some(PathBuf::from("/")), false, Confined),
1197 ("{{ 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 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 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 #[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 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 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 assert!(sink.partition_event(&event).is_some());
1305 }
1306
1307 #[tokio::test]
1308 async fn vector_validate_no_fs_io() {
1309 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 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 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}