Files
RustProxy/src/admin/file_logs_io.rs
T
2026-10-11 17:10:04 +02:00

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()))
}