use std::io::Write;
use std::path::{Path, PathBuf};
use bytes::Bytes;
use globset::{Glob, GlobSetBuilder};
use sha2::{Digest, Sha256};
use super::error::DrainError;
pub const DEFAULT_MAX_FILE_BYTES: u64 = 64 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[non_exhaustive]
pub enum Level {
Trace,
Debug,
Info,
Warn,
Error,
}
impl Level {
fn parse(token: &str) -> Option<Self> {
match token {
"TRACE" => Some(Self::Trace),
"DEBUG" => Some(Self::Debug),
"INFO" => Some(Self::Info),
"WARN" | "WARNING" => Some(Self::Warn),
"ERROR" => Some(Self::Error),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct LogSource {
pub crate_name: String,
pub root: PathBuf,
pub include: Vec<String>,
pub level_filter: Option<Level>,
}
#[derive(Debug, Clone)]
pub struct CollectedFile {
pub relative_key: String,
pub body: Bytes,
pub sha256_plaintext: String,
pub plaintext_len: u64,
pub mtime_unix: i64,
pub source_path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct OversizeFile {
pub path: PathBuf,
pub size: u64,
}
#[derive(Debug, Default)]
pub struct Collected {
pub files: Vec<CollectedFile>,
pub oversize: Vec<OversizeFile>,
pub errors: Vec<(PathBuf, String)>,
}
pub fn collect(
sources: &[LogSource],
secrets: &[String],
max_file_bytes: u64,
) -> Result<Collected, DrainError> {
let mut out = Collected::default();
for source in sources {
let mut builder = GlobSetBuilder::new();
for pattern in &source.include {
let glob = Glob::new(pattern).map_err(|e| DrainError::Uri {
uri: pattern.clone(),
reason: format!("invalid include glob: {e}"),
})?;
builder.add(glob);
}
let globs = builder.build().map_err(|e| DrainError::Uri {
uri: source.include.join(","),
reason: format!("could not compile include globs: {e}"),
})?;
for entry in walkdir::WalkDir::new(&source.root)
.follow_links(false)
.into_iter()
.filter_map(Result::ok)
{
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
let Ok(relative) = path.strip_prefix(&source.root) else {
continue;
};
if !globs.is_match(relative) {
continue;
}
process_file(source, path, relative, secrets, max_file_bytes, &mut out);
}
}
Ok(out)
}
fn process_file(
source: &LogSource,
path: &Path,
relative: &Path,
secrets: &[String],
max_file_bytes: u64,
out: &mut Collected,
) {
let metadata = match std::fs::metadata(path) {
Ok(m) => m,
Err(e) => {
out.errors.push((path.to_path_buf(), e.to_string()));
return;
}
};
let size = metadata.len();
if size > max_file_bytes {
tracing::warn!(
path = %path.display(),
size,
max_file_bytes,
"log-drain skipping oversize file; it will not be uploaded"
);
out.oversize.push(OversizeFile {
path: path.to_path_buf(),
size,
});
return;
}
let plaintext = match std::fs::read(path) {
Ok(b) => b,
Err(e) => {
out.errors.push((path.to_path_buf(), e.to_string()));
return;
}
};
let sha256_plaintext = hex_digest(&plaintext);
let mtime_unix = mtime_seconds(&metadata);
let text = String::from_utf8_lossy(&plaintext);
let filtered = match source.level_filter {
Some(min) => filter_by_level(&text, min),
None => text.into_owned(),
};
let scrubbed = crate::credentials::scrub_secrets(&filtered, secrets);
let body = match gzip(scrubbed.as_bytes()) {
Ok(b) => b,
Err(e) => {
out.errors.push((path.to_path_buf(), e.to_string()));
return;
}
};
out.files.push(CollectedFile {
relative_key: format!("{}/{}", source.crate_name, relative.to_string_lossy()),
body: Bytes::from(body),
sha256_plaintext,
plaintext_len: size,
mtime_unix,
source_path: path.to_path_buf(),
});
}
fn filter_by_level(text: &str, min: Level) -> String {
let mut saw_any_level = false;
let mut keeping = true;
let mut kept = String::with_capacity(text.len());
for line in text.split_inclusive('\n') {
if let Some(level) = line_level(line) {
saw_any_level = true;
keeping = level >= min;
}
if keeping {
kept.push_str(line);
}
}
if saw_any_level {
kept
} else {
text.to_string()
}
}
fn line_level(line: &str) -> Option<Level> {
let plain = strip_ansi(line);
plain
.split_whitespace()
.take(4)
.find_map(|token| Level::parse(token.trim_matches(|c: char| !c.is_ascii_alphabetic())))
}
fn strip_ansi(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut chars = line.chars();
while let Some(c) = chars.next() {
if c != '\u{1b}' {
out.push(c);
continue;
}
if chars.next() != Some('[') {
continue;
}
for tail in chars.by_ref() {
if ('\u{40}'..='\u{7e}').contains(&tail) {
break;
}
}
}
out
}
fn gzip(body: &[u8]) -> std::io::Result<Vec<u8>> {
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(body)?;
encoder.finish()
}
fn hex_digest(body: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(body);
format!("{:x}", hasher.finalize())
}
fn mtime_seconds(metadata: &std::fs::Metadata) -> i64 {
metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map_or(0, |d| d.as_secs() as i64)
}