// Copyright 2024 RustFS Team // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //! Input expansion: files, directories, archives (.zip/.tar/.tar.gz/.zst/.gz), //! and arbitrary readers -> line-parsed [`LogEvent`]s via a callback. //! //! Nothing is ever extracted to disk: archive entries are streamed (or, for //! nested zips that need `Seek`, buffered in memory under a cap), and entry //! paths are only used as provenance strings — which makes the walker immune //! to zip-slip by construction. A single unreadable input is recorded in //! [`IngestReport::skipped`] and never fails the whole run; only a missing //! top-level path returns `Err`. mod detect; mod report; pub use report::{IngestReport, SkipReason}; use crate::model::LogEvent; use crate::parse::LineParser; use detect::{Format, detect, looks_binary}; use std::fs::File; use std::io::{BufReader, Cursor, Read, Seek, SeekFrom}; use std::path::Path; use std::sync::Arc; const PEEK_LEN: usize = 8 * 1024; #[derive(Debug, Clone)] pub struct IngestOptions { /// Max archive nesting depth (gzip->tar counts 2). Default 3. pub max_depth: u32, /// Max entries processed per archive. Default 10_000. pub max_entries_per_archive: u64, /// Global cap on decompressed bytes fed to parsers. Default 8 GiB. pub max_total_bytes: u64, /// Nested archives (which need Seek) are buffered in memory up to this. Default 512 MiB. pub max_in_memory_archive: u64, /// Longest single line kept in memory; the excess is discarded (but still /// counted against `max_total_bytes`) and disclosed once per file. Default 1 MiB. pub max_line_bytes: usize, } impl Default for IngestOptions { fn default() -> Self { Self { max_depth: 3, max_entries_per_archive: 10_000, max_total_bytes: 8 * 1024 * 1024 * 1024, max_in_memory_archive: 512 * 1024 * 1024, max_line_bytes: 1024 * 1024, } } } struct Ctx<'a> { opts: &'a IngestOptions, report: IngestReport, on_event: &'a mut dyn FnMut(LogEvent), byte_budget_exhausted: bool, } impl Ctx<'_> { fn skip(&mut self, provenance: impl Into, reason: SkipReason) { self.report.skipped.push((provenance.into(), reason)); } } /// Ingests a file or directory. Directories are walked recursively without /// following symlinks; the first-level subdirectory name becomes the node /// label (see rustfs/backlog#1284 for the full labeling rules). pub fn ingest_path(path: &Path, opts: &IngestOptions, on_event: &mut dyn FnMut(LogEvent)) -> std::io::Result { let meta = std::fs::metadata(path)?; let mut ctx = Ctx { opts, report: IngestReport::default(), on_event, byte_budget_exhausted: false, }; if meta.is_dir() { // Directory provenance is "/" — readable in // report samples, unlike a full absolute path. let root_label = path .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(|| path.display().to_string()); for entry in walkdir::WalkDir::new(path).follow_links(false).sort_by_file_name() { let entry = match entry { Ok(e) => e, Err(err) => { let at = err .path() .map(|p| p.display().to_string()) .unwrap_or_else(|| path.display().to_string()); ctx.skip(at, SkipReason::Unreadable); continue; } }; if !entry.file_type().is_file() { continue; } let provenance = match entry.path().strip_prefix(path) { Ok(rel) => format!("{root_label}/{}", rel.display()), Err(_) => entry.path().display().to_string(), }; if ctx.byte_budget_exhausted { ctx.skip(provenance, SkipReason::ByteCap); continue; } let node = entry .path() .strip_prefix(path) .ok() .and_then(|rel| rel.parent()?.components().next()) .and_then(|c| match c { std::path::Component::Normal(os) => os.to_str().map(Arc::from), _ => None, }); match File::open(entry.path()) { Ok(file) => walk_file(file, provenance, node, &mut ctx), Err(_) => ctx.skip(provenance, SkipReason::Unreadable), } } } else { let file = File::open(path)?; walk_file(file, path.display().to_string(), None, &mut ctx); } Ok(ctx.report) } /// Ingests an arbitrary reader (stdin, in-memory buffers). `name` seeds the /// provenance string and the extension-based format fallback. pub fn ingest_reader( reader: &mut dyn Read, name: &str, opts: &IngestOptions, on_event: &mut dyn FnMut(LogEvent), ) -> std::io::Result { let mut ctx = Ctx { opts, report: IngestReport::default(), on_event, byte_budget_exhausted: false, }; walk_stream(reader, name.to_string(), name.to_string(), None, 0, &mut ctx); Ok(ctx.report) } /// Top-level files get a seekable fast path so large customer zips are read /// directly from disk instead of being buffered in memory. fn walk_file(mut file: File, provenance: String, node: Option>, ctx: &mut Ctx) { let mut head = Vec::with_capacity(PEEK_LEN); if (&mut file).take(PEEK_LEN as u64).read_to_end(&mut head).is_err() || file.seek(SeekFrom::Start(0)).is_err() { ctx.skip(provenance, SkipReason::Unreadable); return; } let name = leaf_name(&provenance).to_string(); if detect(&head, &name) == Format::Zip { match zip::ZipArchive::new(file) { Ok(mut archive) => walk_zip_entries(&mut archive, &provenance, &node, 0, ctx), Err(_) => ctx.skip(provenance, SkipReason::Unreadable), } return; } walk_stream(&mut file, provenance, name, node, 0, ctx); } /// Core recursive walker over a non-seekable stream. `effective_name` is the /// name used for extension fallback; each decompression layer strips its own /// extension from it (`x.tar.gz` -> `x.tar`) so re-detection terminates. fn walk_stream( reader: &mut dyn Read, provenance: String, effective_name: String, node: Option>, depth: u32, ctx: &mut Ctx, ) { if ctx.byte_budget_exhausted { ctx.skip(provenance, SkipReason::ByteCap); return; } if depth > ctx.opts.max_depth { ctx.skip(provenance, SkipReason::TooDeep); return; } let mut head = Vec::with_capacity(PEEK_LEN); if reader.take(PEEK_LEN as u64).read_to_end(&mut head).is_err() { ctx.skip(provenance, SkipReason::Unreadable); return; } let mut full = Cursor::new(head.clone()).chain(reader); match detect(&head, &effective_name) { Format::Plain => { if looks_binary(&head) { ctx.skip(provenance, SkipReason::Binary); return; } parse_lines(&mut full, provenance, node, ctx); } Format::Gzip => { let inner_name = strip_ext(&effective_name, &[".gz", ".tgz"]); let mut decoder = flate2::read::MultiGzDecoder::new(full); walk_stream(&mut decoder, provenance, inner_name, node, depth + 1, ctx); } Format::Zstd => { let inner_name = strip_ext(&effective_name, &[".zst"]); match zstd::stream::read::Decoder::new(full) { Ok(mut decoder) => walk_stream(&mut decoder, provenance, inner_name, node, depth + 1, ctx), Err(_) => ctx.skip(provenance, SkipReason::Unreadable), } } Format::Tar => { let mut archive = tar::Archive::new(full); let entries = match archive.entries() { Ok(entries) => entries, Err(_) => { ctx.skip(provenance, SkipReason::Unreadable); return; } }; let mut count = 0u64; for entry in entries { let mut entry = match entry { Ok(e) => e, Err(_) => { // A corrupt tar stream cannot be resynchronized. ctx.skip(provenance.clone(), SkipReason::Unreadable); break; } }; if !entry.header().entry_type().is_file() { continue; } count += 1; if count > ctx.opts.max_entries_per_archive { ctx.skip(provenance.clone(), SkipReason::EntryCap); break; } let entry_path = entry .path() .map(|p| p.display().to_string()) .unwrap_or_else(|_| "".to_string()); let child_provenance = format!("{provenance}!{entry_path}"); let child_node = node_from_entry_path(&entry_path).or_else(|| node.clone()); let child_name = leaf_name(&entry_path).to_string(); walk_stream(&mut entry, child_provenance, child_name, child_node, depth + 1, ctx); } } Format::Zip => { // Nested zips need Seek: buffer in memory under the cap. let cap = ctx.opts.max_in_memory_archive; let mut buf = Vec::new(); match full.take(cap + 1).read_to_end(&mut buf) { Ok(n) if n as u64 > cap => { ctx.skip(provenance, SkipReason::ArchiveTooLargeForMemory); return; } Ok(_) => {} Err(_) => { ctx.skip(provenance, SkipReason::Unreadable); return; } } match zip::ZipArchive::new(Cursor::new(buf)) { Ok(mut archive) => walk_zip_entries(&mut archive, &provenance, &node, depth, ctx), Err(_) => ctx.skip(provenance, SkipReason::Unreadable), } } } } fn walk_zip_entries( archive: &mut zip::ZipArchive, provenance: &str, node: &Option>, depth: u32, ctx: &mut Ctx, ) { let total = archive.len(); for index in 0..total { if index as u64 >= ctx.opts.max_entries_per_archive { ctx.skip(provenance.to_string(), SkipReason::EntryCap); break; } let mut entry = match archive.by_index(index) { Ok(e) => e, Err(_) => { ctx.skip(format!("{provenance}!"), SkipReason::Unreadable); continue; } }; if entry.is_dir() { continue; } let entry_path = entry.name().to_string(); let child_provenance = format!("{provenance}!{entry_path}"); let child_node = node_from_entry_path(&entry_path).or_else(|| node.clone()); let child_name = leaf_name(&entry_path).to_string(); walk_stream(&mut entry, child_provenance, child_name, child_node, depth + 1, ctx); } } fn parse_lines(reader: &mut dyn Read, provenance: String, node: Option>, ctx: &mut Ctx) { let file: Arc = Arc::from(provenance.as_str()); let mut parser = LineParser::new(Arc::clone(&file), node); let mut buffered = BufReader::with_capacity(64 * 1024, reader); let mut raw = Vec::new(); let mut line_no = 0u64; let mut long_line_disclosed = false; loop { raw.clear(); let remaining = ctx.opts.max_total_bytes - ctx.report.bytes_fed; let line = match read_line_capped(&mut buffered, &mut raw, ctx.opts.max_line_bytes, remaining) { Ok(line) => line, Err(_) => { ctx.skip(file.to_string(), SkipReason::Unreadable); break; } }; ctx.report.bytes_fed += line.consumed; if line.hit_budget { ctx.byte_budget_exhausted = true; ctx.skip(file.to_string(), SkipReason::ByteCap); break; } if line.consumed == 0 { break; } if line.truncated && !long_line_disclosed { long_line_disclosed = true; ctx.skip(file.to_string(), SkipReason::LineTooLong); } line_no += 1; let text = String::from_utf8_lossy(&raw); let line = text.trim_end_matches(['\n', '\r']); for event in parser.feed(line, line_no) { (ctx.on_event)(event); } } if let Some(event) = parser.finish() { (ctx.on_event)(event); } ctx.report.files_parsed += 1; ctx.report.stats.merge(parser.stats()); } struct LineRead { /// Bytes consumed from the stream, including any discarded over-cap tail. consumed: u64, /// The line exceeded `max_line` and only its prefix is in the buffer. truncated: bool, /// Consuming the next chunk would exceed the global byte budget. hit_budget: bool, } /// Bounded replacement for `read_until(b'\n')`: never grows `buf` beyond /// `max_line` and never consumes past `remaining`, so a single multi-GB line /// (a decompression bomb, or a corrupt file with no newlines) cannot allocate /// unboundedly before the caps are enforced (PR #4876 review). fn read_line_capped( reader: &mut impl std::io::BufRead, buf: &mut Vec, max_line: usize, mut remaining: u64, ) -> std::io::Result { let mut consumed = 0u64; let mut truncated = false; loop { let available = reader.fill_buf()?; if available.is_empty() { return Ok(LineRead { consumed, truncated, hit_budget: false, }); } let newline = available.iter().position(|&b| b == b'\n'); let take = newline.map_or(available.len(), |pos| pos + 1); if take as u64 > remaining { return Ok(LineRead { consumed, truncated, hit_budget: true, }); } let room = max_line.saturating_sub(buf.len()); let stored = take.min(room); buf.extend_from_slice(&available[..stored]); if stored < take { truncated = true; } reader.consume(take); consumed += take as u64; remaining -= take as u64; if newline.is_some() { return Ok(LineRead { consumed, truncated, hit_budget: false, }); } } } /// First path segment of an archive entry path, if any ("node1/rustfs.log" /// -> "node1"). Leading "./" segments are ignored. fn node_from_entry_path(path: &str) -> Option> { let path = path.trim_start_matches("./"); let (first, rest) = path.split_once('/')?; if first.is_empty() || rest.is_empty() { return None; } Some(Arc::from(first)) } fn leaf_name(path: &str) -> &str { path.rsplit(['/', '!']).next().unwrap_or(path) } fn strip_ext(name: &str, exts: &[&str]) -> String { let lower = name.to_ascii_lowercase(); for ext in exts { if lower.ends_with(ext) { let stripped = &name[..name.len() - ext.len()]; // `.tgz` is gzip-wrapped tar: keep re-detection working. if ext.eq_ignore_ascii_case(".tgz") { return format!("{stripped}.tar"); } return stripped.to_string(); } } name.to_string() } #[cfg(test)] mod tests { use super::*; use crate::model::EventKind; use std::io::Write; const JSON_LINE: &str = r#"{"timestamp":"2026-07-15T14:23:01.123456+08:00","level":"ERROR","target":"rustfs::scanner::io","message":"Disk health check marked disk faulty","reason":"faulty_disk"}"#; fn collect(path: &Path, opts: &IngestOptions) -> (Vec, IngestReport) { let mut events = Vec::new(); let report = ingest_path(path, opts, &mut |ev| events.push(ev)).expect("ingest"); (events, report) } fn write_file(dir: &Path, rel: &str, content: &[u8]) -> std::path::PathBuf { let path = dir.join(rel); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).expect("mkdir"); } std::fs::write(&path, content).expect("write"); path } fn zip_bytes(entries: &[(&str, &[u8])]) -> Vec { let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); for (name, data) in entries { writer .start_file(*name, zip::write::SimpleFileOptions::default()) .expect("start_file"); writer.write_all(data).expect("write entry"); } writer.finish().expect("finish").into_inner() } fn tar_gz_bytes(entries: &[(&str, &[u8])]) -> Vec { let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); let mut builder = tar::Builder::new(gz); for (name, data) in entries { let mut header = tar::Header::new_gnu(); header.set_size(data.len() as u64); header.set_mode(0o644); header.set_cksum(); builder.append_data(&mut header, *name, *data).expect("append"); } builder.into_inner().expect("tar finish").finish().expect("gz finish") } fn zstd_bytes(data: &[u8]) -> Vec { zstd::stream::encode_all(Cursor::new(data), 3).expect("zstd") } #[test] fn directory_with_subdir_assigns_node_labels() { let dir = tempfile::tempdir().expect("tempdir"); write_file(dir.path(), "a.log", format!("{JSON_LINE}\n{JSON_LINE}\n").as_bytes()); write_file(dir.path(), "node2/b.log", format!("{JSON_LINE}\n").as_bytes()); let (events, report) = collect(dir.path(), &IngestOptions::default()); assert_eq!(events.len(), 3); assert_eq!(report.files_parsed, 2); assert_eq!(events.iter().filter(|e| e.node.is_none()).count(), 2); assert_eq!(events.iter().filter(|e| e.node.as_deref() == Some("node2")).count(), 1); } #[test] fn zip_with_per_node_directories() { let dir = tempfile::tempdir().expect("tempdir"); let zip = zip_bytes(&[ ("node1/rustfs.log", format!("{JSON_LINE}\n").as_bytes()), ("node2/rustfs.log", format!("{JSON_LINE}\n").as_bytes()), ]); let path = write_file(dir.path(), "customer.zip", &zip); let (events, report) = collect(&path, &IngestOptions::default()); assert_eq!(events.len(), 2); assert_eq!(events[0].node.as_deref(), Some("node1")); assert_eq!(events[1].node.as_deref(), Some("node2")); assert!( events[0].source.file.contains("customer.zip!node1/rustfs.log"), "got {}", events[0].source.file ); assert!(report.skipped.is_empty()); } #[test] fn zstd_and_multi_member_gzip_files() { let dir = tempfile::tempdir().expect("tempdir"); let zst = write_file(dir.path(), "rustfs.log.zst", &zstd_bytes(format!("{JSON_LINE}\n").as_bytes())); let (events, _) = collect(&zst, &IngestOptions::default()); assert_eq!(events.len(), 1); assert_eq!(events[0].kind, EventKind::Json); // Two concatenated gzip members must both be decoded (MultiGzDecoder). let mut gz = Vec::new(); for _ in 0..2 { let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); enc.write_all(format!("{JSON_LINE}\n").as_bytes()).expect("write"); gz.extend(enc.finish().expect("finish")); } let gz_path = write_file(dir.path(), "rustfs.log.gz", &gz); let (events, _) = collect(&gz_path, &IngestOptions::default()); assert_eq!(events.len(), 2); } #[test] fn tar_gz_containing_zst_is_three_layers() { let dir = tempfile::tempdir().expect("tempdir"); let inner_zst = zstd_bytes(format!("{JSON_LINE}\n").as_bytes()); let targz = tar_gz_bytes(&[("var/log/archive.rustfs.log.zst", &inner_zst)]); let path = write_file(dir.path(), "logs.tar.gz", &targz); let (events, report) = collect(&path, &IngestOptions::default()); assert_eq!(events.len(), 1, "skipped: {:?}", report.skipped); assert_eq!(events[0].node.as_deref(), Some("var")); assert!(events[0].source.file.ends_with("logs.tar.gz!var/log/archive.rustfs.log.zst")); // gzip(1) -> tar entry(2) -> zstd(3) -> plain needs depth 4 > max 2. let tight = IngestOptions { max_depth: 2, ..Default::default() }; let (events, report) = collect(&path, &tight); assert!(events.is_empty()); assert_eq!(report.skipped.last().expect("skip").1, SkipReason::TooDeep); } #[test] fn nested_zip_respects_memory_cap() { let dir = tempfile::tempdir().expect("tempdir"); let inner = zip_bytes(&[("inner/rustfs.log", format!("{JSON_LINE}\n").as_bytes())]); let outer = zip_bytes(&[("bundle/inner.zip", &inner)]); let path = write_file(dir.path(), "outer.zip", &outer); let (events, _) = collect(&path, &IngestOptions::default()); assert_eq!(events.len(), 1); // Inner zip entry path wins over the outer "bundle" label. assert_eq!(events[0].node.as_deref(), Some("inner")); let tiny = IngestOptions { max_in_memory_archive: 1, ..Default::default() }; let (events, report) = collect(&path, &tiny); assert!(events.is_empty()); assert_eq!(report.skipped.last().expect("skip").1, SkipReason::ArchiveTooLargeForMemory); } #[test] fn binary_files_are_skipped() { let dir = tempfile::tempdir().expect("tempdir"); let path = write_file(dir.path(), "core.bin", &vec![0u8; 1024]); let (events, report) = collect(&path, &IngestOptions::default()); assert!(events.is_empty()); assert_eq!(report.skipped, vec![(path.display().to_string(), SkipReason::Binary)]); assert_eq!(report.files_parsed, 0); } #[test] fn byte_cap_stops_ingest_but_keeps_prior_events() { let dir = tempfile::tempdir().expect("tempdir"); write_file(dir.path(), "a.log", format!("{JSON_LINE}\n{JSON_LINE}\n{JSON_LINE}\n").as_bytes()); write_file(dir.path(), "b.log", format!("{JSON_LINE}\n").as_bytes()); let opts = IngestOptions { max_total_bytes: (JSON_LINE.len() + 1) as u64, ..Default::default() }; let (events, report) = collect(dir.path(), &opts); assert_eq!(events.len(), 1); assert!(report.bytes_fed <= opts.max_total_bytes); let reasons: Vec<_> = report.skipped.iter().map(|(_, r)| *r).collect(); assert!(reasons.contains(&SkipReason::ByteCap)); // The second file is skipped up-front once the budget is gone. assert_eq!(reasons.iter().filter(|r| **r == SkipReason::ByteCap).count(), 2); } #[test] fn overlong_line_is_truncated_disclosed_and_fully_charged() { let dir = tempfile::tempdir().expect("tempdir"); // One 4 KiB line with no newline until the end, then a normal line. let long = format!("ERROR long prefix {}\n{JSON_LINE}\n", "x".repeat(4096)); let path = write_file(dir.path(), "a.log", long.as_bytes()); let opts = IngestOptions { max_line_bytes: 256, ..Default::default() }; let (events, report) = collect(&path, &opts); // Truncated prefix still parses; the (shorter) JSON line after it is intact. assert_eq!(events.len(), 2, "skipped: {:?}", report.skipped); assert!(events[0].message.len() <= 256); assert!(events[0].message.starts_with("ERROR long prefix")); assert_eq!(events[1].kind, EventKind::Json); // Discarded tail bytes still count against the global budget. assert_eq!(report.bytes_fed, long.len() as u64); // Disclosed exactly once per file. assert_eq!(report.skipped.iter().filter(|(_, r)| *r == SkipReason::LineTooLong).count(), 1); assert_eq!(report.files_parsed, 1); } #[test] fn single_line_larger_than_byte_budget_stops_without_buffering_it() { let dir = tempfile::tempdir().expect("tempdir"); // A single huge line with no trailing newline: the pre-fix code buffered // it whole via read_until before checking the cap. let path = write_file(dir.path(), "bomb.log", "y".repeat(256 * 1024).as_bytes()); let opts = IngestOptions { max_total_bytes: 1024, max_line_bytes: 64, ..Default::default() }; let (events, report) = collect(&path, &opts); assert!(events.is_empty()); assert!(report.bytes_fed <= opts.max_total_bytes); assert_eq!(report.skipped.last().expect("skip").1, SkipReason::ByteCap); } #[test] fn hostile_entry_paths_never_touch_the_filesystem() { let dir = tempfile::tempdir().expect("tempdir"); // tar::Builder refuses `..` paths, so write the raw GNU header name // directly — real hostile archives do exactly this. let data = format!("{JSON_LINE}\n"); let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); let mut builder = tar::Builder::new(gz); let mut header = tar::Header::new_gnu(); { let gnu = header.as_gnu_mut().expect("gnu header"); let name = b"../escape.log"; gnu.name[..name.len()].copy_from_slice(name); } header.set_size(data.len() as u64); header.set_mode(0o644); header.set_cksum(); builder.append(&header, data.as_bytes()).expect("append"); let targz = builder.into_inner().expect("tar finish").finish().expect("gz finish"); let path = write_file(dir.path(), "hostile.tar.gz", &targz); let scratch = tempfile::tempdir().expect("tempdir"); let before: Vec<_> = std::fs::read_dir(scratch.path()).expect("read_dir").collect(); let (events, _) = collect(&path, &IngestOptions::default()); assert_eq!(events.len(), 1); assert!(events[0].source.file.ends_with("!../escape.log")); let after: Vec<_> = std::fs::read_dir(scratch.path()).expect("read_dir").collect(); assert_eq!(before.len(), after.len()); assert!(!dir.path().parent().expect("parent").join("escape.log").exists()); } #[test] fn missing_path_errors_unreadable_file_skips() { let missing = ingest_path(Path::new("/nonexistent/definitely-not-here"), &IngestOptions::default(), &mut |_| {}); assert!(missing.is_err()); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().expect("tempdir"); write_file(dir.path(), "ok.log", format!("{JSON_LINE}\n").as_bytes()); let locked = write_file(dir.path(), "locked.log", b"secret\n"); std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).expect("chmod"); let (events, report) = collect(dir.path(), &IngestOptions::default()); assert_eq!(events.len(), 1); // Directory provenance is "/". let root_name = dir.path().file_name().expect("name").to_string_lossy(); assert_eq!(report.skipped, vec![(format!("{root_name}/locked.log"), SkipReason::Unreadable)]); std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o644)).expect("chmod back"); } } #[test] fn ingest_reader_handles_stdin_like_streams() { let data = format!("{JSON_LINE}\nplain trailer\n"); let mut cursor = Cursor::new(data.into_bytes()); let mut events = Vec::new(); let report = ingest_reader(&mut cursor, "stdin", &IngestOptions::default(), &mut |ev| events.push(ev)).expect("ingest"); assert_eq!(events.len(), 2); assert_eq!(&*events[0].source.file, "stdin"); assert_eq!(events[1].kind, EventKind::Text); assert_eq!(report.stats.total_lines, 2); assert_eq!(report.stats.json_ok, 1); assert_eq!(report.stats.text_lines, 1); } }