use std::io::{Read, Write};
use std::path::Path;
use bytes::Bytes;
use sha2::{Digest, Sha256};
const READ_CHUNK_BYTES: usize = 1024 * 1024;
const MAX_PENDING_BYTES: usize = 8 * 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,
}
}
}
pub(super) enum StreamOutcome {
Body {
body: Bytes,
sha256_plaintext: String,
},
CompressedTooLarge,
}
pub(super) fn stream_file(
path: &Path,
level_filter: Option<Level>,
secrets: &[String],
max_wire_bytes: u64,
) -> std::io::Result<StreamOutcome> {
let mut reader =
std::io::BufReader::with_capacity(READ_CHUNK_BYTES, std::fs::File::open(path)?);
let mut hasher = Sha256::new();
let mut encoder =
flate2::write::GzEncoder::new(Vec::<u8>::new(), flate2::Compression::default());
let mut filter = LineFilter::new(level_filter);
let mut scrub = ScrubCarry::new(secrets);
let mut raw = vec![0u8; READ_CHUNK_BYTES];
let mut pending: Vec<u8> = Vec::with_capacity(READ_CHUNK_BYTES);
loop {
let read = reader.read(&mut raw)?;
if read == 0 {
break;
}
hasher.update(&raw[..read]);
pending.extend_from_slice(&raw[..read]);
let Some(split) = flush_point(&pending) else {
continue;
};
let chunk: Vec<u8> = pending.drain(..split).collect();
encoder.write_all(scrub.push(&filter.push(&decode(&chunk))).as_bytes())?;
if encoder.get_ref().len() as u64 > max_wire_bytes {
return Ok(StreamOutcome::CompressedTooLarge);
}
}
if !pending.is_empty() {
encoder.write_all(scrub.push(&filter.push(&decode(&pending))).as_bytes())?;
}
encoder.write_all(scrub.finish().as_bytes())?;
let body = encoder.finish()?;
if body.len() as u64 > max_wire_bytes {
return Ok(StreamOutcome::CompressedTooLarge);
}
Ok(StreamOutcome::Body {
body: Bytes::from(body),
sha256_plaintext: format!("{:x}", hasher.finalize()),
})
}
pub(super) fn hex_digest(body: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(body);
format!("{:x}", hasher.finalize())
}
fn flush_point(pending: &[u8]) -> Option<usize> {
if let Some(last) = pending.iter().rposition(|b| *b == b'\n') {
return Some(last + 1);
}
if pending.len() < MAX_PENDING_BYTES {
return None;
}
let mut end = pending.len();
let floor = end.saturating_sub(4);
while end > floor && (pending[end - 1] & 0b1100_0000) == 0b1000_0000 {
end -= 1;
}
if end > floor && pending[end - 1] >= 0b1100_0000 {
end -= 1;
}
Some(end)
}
fn decode(chunk: &[u8]) -> String {
String::from_utf8_lossy(chunk).into_owned()
}
struct LineFilter {
min: Option<Level>,
keeping: bool,
}
impl LineFilter {
fn new(min: Option<Level>) -> Self {
Self { min, keeping: true }
}
fn push(&mut self, text: &str) -> String {
let Some(min) = self.min else {
return text.to_string();
};
let mut kept = String::with_capacity(text.len());
for line in text.split_inclusive('\n') {
if let Some(level) = line_level(line) {
self.keeping = level >= min;
}
if self.keeping {
kept.push_str(line);
}
}
kept
}
}
struct ScrubCarry {
secrets: Vec<String>,
window: usize,
carry: String,
}
impl ScrubCarry {
fn new(secrets: &[String]) -> Self {
let window = secrets
.iter()
.map(String::len)
.max()
.unwrap_or(0)
.saturating_sub(1);
Self {
secrets: secrets.to_vec(),
window,
carry: String::new(),
}
}
fn push(&mut self, text: &str) -> String {
if self.window == 0 {
return crate::credentials::scrub_secrets(text, &self.secrets);
}
let mut buf = std::mem::take(&mut self.carry);
buf.push_str(text);
let scrubbed = crate::credentials::scrub_secrets(&buf, &self.secrets);
let split = floor_char_boundary(&scrubbed, scrubbed.len().saturating_sub(self.window));
self.carry = scrubbed[split..].to_string();
scrubbed[..split].to_string()
}
fn finish(&mut self) -> String {
std::mem::take(&mut self.carry)
}
}
fn floor_char_boundary(text: &str, mut index: usize) -> usize {
if index >= text.len() {
return text.len();
}
while index > 0 && !text.is_char_boundary(index) {
index -= 1;
}
index
}
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
}