132 lines
5.3 KiB
Rust
132 lines
5.3 KiB
Rust
// Bound candidate retention while scanning potentially enormous rotation folders.
|
|
// The heap's top is the least desirable result: oldest timestamp, then
|
|
// lexicographically last name. Final output preserves newest/name order.
|
|
fn newest_log_files(
|
|
candidates: impl Iterator<Item = (String, std::path::PathBuf, u64)>,
|
|
limit: usize,
|
|
) -> Vec<(String, std::path::PathBuf, u64)> {
|
|
use std::{cmp::Reverse, collections::BinaryHeap};
|
|
if limit == 0 { return Vec::new(); }
|
|
let mut bounded = BinaryHeap::with_capacity(limit);
|
|
for (name, path, modified_ms) in candidates {
|
|
let item = (Reverse(modified_ms), name, path);
|
|
if bounded.len() < limit {
|
|
bounded.push(item);
|
|
} else if bounded.peek().is_some_and(|worst| item.cmp(worst).is_lt()) {
|
|
bounded.pop();
|
|
bounded.push(item);
|
|
}
|
|
}
|
|
let mut result = bounded.into_iter()
|
|
.map(|(Reverse(modified), name, path)| (name, path, modified))
|
|
.collect::<Vec<_>>();
|
|
result.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
|
|
result
|
|
}
|
|
|
|
// Bounded file-log inventory helpers and gzip tail reader.
|
|
fn rotated_gzip_sources(
|
|
active: &std::path::Path,
|
|
id_prefix: &str,
|
|
label_prefix: &str,
|
|
kind: &'static str,
|
|
enabled: bool,
|
|
format: &'static str,
|
|
) -> Vec<FileLogSource> {
|
|
let Some(parent) = active.parent() else { return Vec::new(); };
|
|
let Some(base) = active.file_name().and_then(|name| name.to_str()) else { return Vec::new(); };
|
|
let safe = std::fs::symlink_metadata(parent).ok().is_some_and(|meta| meta.file_type().is_dir() && !meta.file_type().is_symlink());
|
|
if !safe { return Vec::new(); }
|
|
let prefix = format!("{base}.");
|
|
let entries = newest_log_files(std::fs::read_dir(parent).ok().into_iter().flat_map(|iter| iter.filter_map(|entry| entry.ok())).filter_map(|entry| {
|
|
let name = entry.file_name().to_string_lossy().into_owned();
|
|
if name.len() > 180 || !name.starts_with(&prefix) || !name.ends_with(".gz") { return None; }
|
|
let file_type = entry.file_type().ok()?;
|
|
if !file_type.is_file() || file_type.is_symlink() { return None; }
|
|
let meta = entry.metadata().ok()?;
|
|
Some((name, entry.path(), file_modified_ms(&meta)))
|
|
}), 64);
|
|
entries.into_iter().map(|(name,path,_)| source_from_path(
|
|
format!("{id_prefix}:gz:{name}"),
|
|
format!("{label_prefix} · {name}"),
|
|
id_prefix.to_owned(),
|
|
kind, path, enabled, format
|
|
)).collect()
|
|
}
|
|
|
|
fn open_file_log(path: &std::path::Path) -> std::io::Result<std::fs::File> {
|
|
let mut options = std::fs::OpenOptions::new();
|
|
options.read(true);
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
options.custom_flags(libc::O_NOFOLLOW);
|
|
}
|
|
let file = options.open(path)?;
|
|
let metadata = file.metadata()?;
|
|
if !metadata.file_type().is_file() {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
"configured log path is not a regular file",
|
|
));
|
|
}
|
|
Ok(file)
|
|
}
|
|
fn read_tail(
|
|
source: &FileLogSource,
|
|
budget: u64,
|
|
line_budget: usize,
|
|
) -> std::io::Result<(Vec<String>, u64, bool)> {
|
|
use std::io::{Read, Seek, SeekFrom};
|
|
let mut file = open_file_log(&source.path)?;
|
|
let length = file.metadata()?.len();
|
|
if source.compressed {
|
|
// gzip streams are not seekable. Read a complete bounded rotation, then
|
|
// keep only the same 2 MiB decompressed tail used for plain files.
|
|
if length > budget || length > FILE_LOG_GZIP_INPUT_MAX {
|
|
return Ok((Vec::new(), length.min(budget), true));
|
|
}
|
|
let mut decoder = flate2::read::GzDecoder::new(file);
|
|
let mut decoded = Vec::new();
|
|
decoder
|
|
.by_ref()
|
|
.take(FILE_LOG_GZIP_OUTPUT_MAX + 1)
|
|
.read_to_end(&mut decoded)?;
|
|
if decoded.len() as u64 > FILE_LOG_GZIP_OUTPUT_MAX {
|
|
return Ok((Vec::new(), length, true));
|
|
}
|
|
let keep = decoded.len().min(FILE_LOG_SCAN_BYTES_PER_SOURCE as usize);
|
|
let start = decoded.len().saturating_sub(keep);
|
|
let mut content = decoded[start..].to_vec();
|
|
let truncated = start > 0;
|
|
if truncated {
|
|
if let Some(index) = content.iter().position(|byte| *byte == b'\n') {
|
|
content.drain(..=index);
|
|
} else {
|
|
content.clear();
|
|
}
|
|
}
|
|
let text = String::from_utf8_lossy(&content);
|
|
let mut iter = text.lines().rev();
|
|
let lines = iter.by_ref().take(line_budget).map(str::to_owned).collect::<Vec<_>>();
|
|
return Ok((lines, length, truncated || iter.next().is_some()));
|
|
}
|
|
let bytes = length.min(budget).min(FILE_LOG_SCAN_BYTES_PER_SOURCE);
|
|
let start = length.saturating_sub(bytes);
|
|
file.seek(SeekFrom::Start(start))?;
|
|
let mut content = Vec::with_capacity(bytes as usize);
|
|
file.take(bytes).read_to_end(&mut content)?;
|
|
let truncated = start > 0;
|
|
if truncated {
|
|
if let Some(index) = content.iter().position(|byte| *byte == b'\n') {
|
|
content.drain(..=index);
|
|
} else {
|
|
content.clear();
|
|
}
|
|
}
|
|
let text = String::from_utf8_lossy(&content);
|
|
let mut iter = text.lines().rev();
|
|
let lines = iter.by_ref().take(line_budget).map(str::to_owned).collect::<Vec<_>>();
|
|
Ok((lines, bytes, truncated || iter.next().is_some()))
|
|
}
|