use std::io::Read;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use crate::{InklogError, open_validated_file};
#[derive(Debug, Clone, Default)]
pub struct QueryOptions {
pub since: Option<DateTime<Utc>>,
pub until: Option<DateTime<Utc>>,
pub level: Option<String>,
pub keyword: Option<String>,
pub limit: usize,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct LogEntry {
pub timestamp: Option<DateTime<Utc>>,
pub level: String,
pub target: String,
pub message: String,
pub source: PathBuf,
}
pub(crate) fn level_rank(level: &str) -> u8 {
match level.to_ascii_uppercase().as_str() {
"TRACE" => 0,
"DEBUG" => 1,
"INFO" => 2,
"WARN" | "WARNING" => 3,
"ERROR" => 4,
"FATAL" | "CRITICAL" => 5,
_ => u8::MAX,
}
}
pub fn read_log_file(path: &Path, key_env: Option<&str>) -> Result<String, InklogError> {
let mut file = open_validated_file(path)?;
let mut raw = Vec::new();
file.read_to_end(&mut raw)?;
if raw.starts_with(ENCRYPTED_MAGIC) {
let env = key_env.ok_or_else(|| {
InklogError::ConfigError(format!(
"encrypted log file '{}' requires a key env var (use --key-env, \
conventionally INKLOG_ENCRYPTION_KEY)",
path.display()
))
})?;
let plaintext = decrypt_bytes(&raw, path, env)?;
return String::from_utf8(plaintext).map_err(|e| {
InklogError::ConfigError(format!(
"decrypted log file '{}' is not valid UTF-8: {e}",
path.display()
))
});
}
match path.extension().and_then(|e| e.to_str()) {
Some("zst") => {
#[cfg(feature = "compression")]
{
let mut decoder =
zstd::stream::Decoder::new(std::io::Cursor::new(&raw)).map_err(|e| {
InklogError::ConfigError(format!(
"zstd decode failed for '{}': {e}",
path.display()
))
})?;
let mut out = String::new();
decoder.read_to_string(&mut out).map_err(|e| {
InklogError::ConfigError(format!(
"zstd decode failed for '{}': {e}",
path.display()
))
})?;
Ok(out)
}
#[cfg(not(feature = "compression"))]
{
let _ = path;
Err(InklogError::ConfigError(
"'.zst' log files require the 'compression' feature".to_string(),
))
}
}
Some("gz") => {
#[cfg(feature = "gzip")]
{
use std::io::Read as _;
let mut decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(&raw));
let mut out = String::new();
decoder.read_to_string(&mut out).map_err(|e| {
InklogError::ConfigError(format!(
"gzip decode failed for '{}': {e}",
path.display()
))
})?;
Ok(out)
}
#[cfg(not(feature = "gzip"))]
{
let _ = path;
Err(InklogError::ConfigError(
"'.gz' log files require the 'gzip' feature".to_string(),
))
}
}
_ => String::from_utf8(raw).map_err(|e| {
InklogError::ConfigError(format!(
"log file '{}' is not valid UTF-8: {e}",
path.display()
))
}),
}
}
const ENCRYPTED_MAGIC: &[u8] = b"ENCLOG1\0";
fn decrypt_bytes(raw: &[u8], path: &Path, key_env: &str) -> Result<Vec<u8>, InklogError> {
use aes_gcm::aead::Aead;
use aes_gcm::{Aes256Gcm, KeyInit};
if raw.len() < 10 {
return Err(InklogError::ConfigError(format!(
"encrypted file '{}' too small for a header",
path.display()
)));
}
let version = u16::from_le_bytes([raw[8], raw[9]]);
let key_from_env = |salt: Option<&[u8]>| -> Result<zeroize::Zeroizing<[u8; 32]>, InklogError> {
match salt {
Some(s) => {
crate::support::io::sink::encryption::get_encryption_key_with_salt(key_env, s)
}
None => crate::support::io::sink::encryption::get_encryption_key(key_env),
}
};
match version {
1 => {
let algo = u16::from_le_bytes([raw[10], raw[11]]);
let (nonce_bytes, ciphertext): ([u8; 12], &[u8]) = if algo == 1 {
if raw.len() < 24 {
return Err(InklogError::ConfigError("truncated v1 header".into()));
}
(raw[12..24].try_into().expect("12 bytes"), &raw[24..])
} else {
if raw.len() < 22 {
return Err(InklogError::ConfigError("truncated legacy header".into()));
}
(raw[10..22].try_into().expect("12 bytes"), &raw[22..])
};
if crate::support::io::sink::encryption::env_key_is_password(key_env) {
return Err(InklogError::ConfigError(
"v1 encrypted files written with a password-derived key are \
unrecoverable: the v1 header does not store the PBKDF2 salt"
.to_string(),
));
}
let key = key_from_env(None)?;
let cipher = Aes256Gcm::new((&*key).into());
let nonce = aes_gcm::Nonce::from(nonce_bytes);
cipher
.decrypt(&nonce, ciphertext)
.map_err(|_| InklogError::ConfigError("decryption failed (wrong key?)".into()))
}
2 => {
if raw.len() < 40 {
return Err(InklogError::ConfigError(format!(
"truncated v2 header in '{}': expected 40 bytes, got {}",
path.display(),
raw.len()
)));
}
let salt: [u8; 16] = raw[12..28].try_into().expect("16 bytes");
let key = key_from_env(Some(&salt))?;
let nonce: [u8; 12] = raw[28..40].try_into().expect("12 bytes");
let cipher = Aes256Gcm::new((&*key).into());
let nonce = aes_gcm::Nonce::from(nonce);
cipher
.decrypt(&nonce, &raw[40..])
.map_err(|_| InklogError::ConfigError("decryption failed (wrong key?)".into()))
}
other => Err(InklogError::ConfigError(format!(
"unsupported encryption version {other} in '{}'",
path.display()
))),
}
}
pub(crate) fn parse_line(line: &str, source: &Path) -> Option<LogEntry> {
let trimmed = line.trim_end();
if trimmed.is_empty() {
return None;
}
if trimmed.starts_with('{')
&& let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed)
{
let timestamp = json
.get("timestamp")
.and_then(|v| v.as_str())
.and_then(parse_timestamp);
return Some(LogEntry {
timestamp,
level: json
.get("level")
.and_then(|v| v.as_str())
.unwrap_or("INFO")
.to_string(),
target: json
.get("target")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
message: json
.get("message")
.map(|v| {
v.as_str()
.map(str::to_string)
.unwrap_or_else(|| v.to_string())
})
.unwrap_or_default(),
source: source.to_path_buf(),
});
}
let ts_end = trimmed.find(" [")?;
let timestamp = parse_timestamp(&trimmed[..ts_end]);
let rest = &trimmed[ts_end + 2..];
let level_end = rest.find(']')?;
let level = rest[..level_end].trim().to_string();
if level.is_empty() || !level.chars().all(|c| c.is_ascii_alphabetic()) {
return None;
}
let after_level = &rest[level_end + 1..];
let after_level = after_level.strip_prefix(' ').unwrap_or(after_level);
let (target, message) = match after_level.split_once(" - ") {
Some((t, m)) => (t.to_string(), m.to_string()),
None => (String::new(), after_level.to_string()),
};
Some(LogEntry {
timestamp,
level,
target,
message,
source: source.to_path_buf(),
})
}
fn parse_timestamp(raw: &str) -> Option<DateTime<Utc>> {
if let Ok(t) = DateTime::parse_from_rfc3339(raw) {
return Some(t.with_timezone(&Utc));
}
chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S%.3fZ")
.ok()
.map(|naive| naive.and_utc())
}
fn expand_paths(inputs: &[PathBuf]) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut stack: Vec<PathBuf> = inputs.to_vec();
while let Some(p) = stack.pop() {
if p.is_dir() {
if let Ok(entries) = std::fs::read_dir(&p) {
for entry in entries.flatten() {
stack.push(entry.path());
}
}
} else if p.is_file() {
out.push(p);
}
}
out.sort();
out
}
pub fn query_paths(
inputs: &[PathBuf],
opts: &QueryOptions,
key_env: Option<&str>,
) -> Result<Vec<LogEntry>, InklogError> {
let min_rank = opts.level.as_deref().map(level_rank);
let mut entries: Vec<LogEntry> = Vec::new();
let mut last_error: Option<InklogError> = None;
for path in expand_paths(inputs) {
let content = match read_log_file(&path, key_env) {
Ok(c) => c,
Err(e) => {
tracing::debug!(target: "inklog::query", error = %e, path = %path.display(), "skip unreadable log file");
last_error = Some(e);
continue;
}
};
for line in content.lines() {
let Some(entry) = parse_line(line, &path) else {
continue;
};
if let Some(min) = min_rank {
let rank = level_rank(&entry.level);
if rank == u8::MAX || rank < min {
continue;
}
}
if let Some(since) = opts.since
&& entry.timestamp.is_none_or(|t| t < since)
{
continue;
}
if let Some(until) = opts.until
&& entry.timestamp.is_none_or(|t| t > until)
{
continue;
}
if let Some(keyword) = &opts.keyword
&& !entry.message.contains(keyword.as_str())
&& !entry.target.contains(keyword.as_str())
{
continue;
}
entries.push(entry);
if opts.limit > 0 && entries.len() >= opts.limit {
return Ok(entries);
}
}
}
if entries.is_empty()
&& let Some(e) = last_error
&& expand_paths(inputs).is_empty()
{
return Err(e);
}
Ok(entries)
}
pub fn query_exit_code(entries: &[LogEntry]) -> i32 {
if entries.is_empty() { 2 } else { 0 }
}
#[cfg(test)]
mod tests {
use super::*;
fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf {
let p = dir.join(name);
std::fs::write(&p, content).unwrap();
p
}
const SAMPLE: &str = "\
2026-09-11T01:00:00.123Z [INFO] app::boot - service started
2026-09-11T02:00:00.000Z [WARN] app::net - slow upstream timeout
2026-09-11T03:30:00.000Z [ERROR] app::db - connection refused
not a log line
2026-09-11T04:00:00.000Z [FATAL] app::db - giving up
";
#[test]
fn test_parse_line_default_template() {
let entry = parse_line(
"2026-09-11T01:00:00.123Z [INFO] app::boot - service started",
Path::new("x.log"),
)
.expect("must parse");
assert_eq!(entry.level, "INFO");
assert_eq!(entry.target, "app::boot");
assert_eq!(entry.message, "service started");
assert_eq!(
entry.timestamp.unwrap().to_rfc3339(),
"2026-09-11T01:00:00.123+00:00"
);
}
#[test]
fn test_parse_line_json_and_garbage() {
let entry = parse_line(
r#"{"timestamp":"2026-09-11T01:00:00Z","level":"WARN","target":"j","message":"{\"k\":1}"}"#,
Path::new("x.log"),
)
.expect("JSON line must parse");
assert_eq!(entry.level, "WARN");
assert_eq!(entry.message, r#"{"k":1}"#);
assert!(parse_line("not a log line", Path::new("x")).is_none());
assert!(parse_line("", Path::new("x")).is_none());
}
#[test]
fn test_query_filters_level_keyword_and_limit() {
let dir = tempfile::tempdir().unwrap();
let f = write_file(dir.path(), "app.log", SAMPLE);
let opts = QueryOptions {
level: Some("warn".into()),
..Default::default()
};
let got = query_paths(std::slice::from_ref(&f), &opts, None).unwrap();
assert_eq!(got.len(), 3);
assert!(got.iter().all(|e| level_rank(&e.level) >= 3));
let opts = QueryOptions {
keyword: Some("connection".into()),
..Default::default()
};
let got = query_paths(std::slice::from_ref(&f), &opts, None).unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].message, "connection refused");
let opts = QueryOptions {
limit: 2,
..Default::default()
};
let got = query_paths(std::slice::from_ref(&f), &opts, None).unwrap();
assert_eq!(got.len(), 2);
let opts = QueryOptions {
since: Some(
DateTime::parse_from_rfc3339("2026-09-11T03:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
until: Some(
DateTime::parse_from_rfc3339("2026-09-11T03:45:00Z")
.unwrap()
.with_timezone(&Utc),
),
..Default::default()
};
let got = query_paths(&[f], &opts, None).unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].level, "ERROR");
}
#[test]
fn test_query_directory_recursive_and_exit_code() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "a.log", SAMPLE);
let sub = dir.path().join("rotated");
std::fs::create_dir_all(&sub).unwrap();
write_file(
&sub,
"b.log",
"2026-09-11T05:00:00.000Z [ERROR] app::x - boom\n",
);
let opts = QueryOptions {
level: Some("error".into()),
..Default::default()
};
let got = query_paths(&[dir.path().to_path_buf()], &opts, None).unwrap();
assert_eq!(got.len(), 3);
assert_eq!(query_exit_code(&got), 0);
let empty = query_paths(
&[dir.path().to_path_buf()],
&QueryOptions {
level: Some("trace".into()),
keyword: Some("nope".into()),
..Default::default()
},
None,
)
.unwrap();
assert!(empty.is_empty());
assert_eq!(query_exit_code(&empty), 2, "no matches → exit code 2");
}
#[test]
fn test_unreadable_file_is_skipped_not_fatal() {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "bad.zst", "not really zstd");
write_file(
dir.path(),
"ok.log",
"2026-09-11T01:00:00.000Z [INFO] a - ok\n",
);
let got = query_paths(&[dir.path().to_path_buf()], &QueryOptions::default(), None).unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].message, "ok");
}
#[test]
#[serial_test::serial]
fn test_encrypted_log_roundtrip_query() {
let dir = tempfile::tempdir().unwrap();
let plaintext = "2026-09-11T01:00:00.000Z [ERROR] app::secret - leaked\n";
unsafe { std::env::set_var("INKLOG_QUERY_TEST_KEY", "0123456789abcdef0123456789abcdef") };
let key = crate::support::io::sink::encryption::get_encryption_key("INKLOG_QUERY_TEST_KEY")
.expect("key must be set");
use aes_gcm::aead::Aead;
use aes_gcm::{Aes256Gcm, KeyInit};
let salt: [u8; 16] = rand::random();
let nonce: [u8; 12] = rand::random();
let cipher = Aes256Gcm::new((&*key).into());
let nonce = aes_gcm::Nonce::from(nonce);
let ciphertext = cipher
.encrypt(&nonce, plaintext.as_bytes())
.expect("encrypt");
let mut blob = Vec::new();
blob.extend_from_slice(ENCRYPTED_MAGIC);
blob.extend_from_slice(&2u16.to_le_bytes());
blob.extend_from_slice(&1u16.to_le_bytes());
blob.extend_from_slice(&salt);
blob.extend_from_slice(&nonce);
blob.extend_from_slice(&ciphertext);
let enc_path = dir.path().join("app.log.enc");
std::fs::write(&enc_path, &blob).unwrap();
let opts = QueryOptions {
level: Some("error".into()),
..Default::default()
};
let got = query_paths(&[enc_path], &opts, Some("INKLOG_QUERY_TEST_KEY")).unwrap();
assert_eq!(got.len(), 1, "encrypted log must be unpacked and searched");
assert_eq!(got[0].message, "leaked");
}
}