v0.26.2
This commit is contained in:
1 parent
b9d137eee1
commit
32e6875980
51 files changed
+1255
-452
No files matched your search
@@ -0,0 +1,384 @@
|
||||
fn append_search_values(value: &Value, output: &mut String) {
|
||||
match value {
|
||||
Value::Null => {}
|
||||
Value::Bool(value) => output.push_str(if *value { " true" } else { " false" }),
|
||||
Value::Number(value) => {
|
||||
output.push(' ');
|
||||
output.push_str(&value.to_string());
|
||||
}
|
||||
Value::String(value) => {
|
||||
output.push(' ');
|
||||
output.push_str(&value.to_ascii_lowercase());
|
||||
}
|
||||
Value::Array(values) => values
|
||||
.iter()
|
||||
.for_each(|value| append_search_values(value, output)),
|
||||
Value::Object(values) => values
|
||||
.values()
|
||||
.for_each(|value| append_search_values(value, output)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum FileLogSearchClause {
|
||||
Any {
|
||||
value: String,
|
||||
negated: bool,
|
||||
},
|
||||
Field {
|
||||
field: String,
|
||||
value: String,
|
||||
negated: bool,
|
||||
},
|
||||
}
|
||||
|
||||
fn tokenize_file_log_search(input: &str) -> Result<Vec<String>, ApiError> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut quoted = false;
|
||||
let mut escaped = false;
|
||||
for ch in input.chars() {
|
||||
if escaped {
|
||||
current.push(ch);
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if quoted && ch == '\\' {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if ch == '"' {
|
||||
quoted = !quoted;
|
||||
continue;
|
||||
}
|
||||
if ch.is_whitespace() && !quoted {
|
||||
if !current.is_empty() {
|
||||
tokens.push(std::mem::take(&mut current));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current.push(ch);
|
||||
}
|
||||
if quoted || escaped {
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid file log search syntax".into(),
|
||||
));
|
||||
}
|
||||
if !current.is_empty() {
|
||||
tokens.push(current);
|
||||
}
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
fn parse_file_log_search(input: &str) -> Result<Vec<FileLogSearchClause>, ApiError> {
|
||||
let tokens = tokenize_file_log_search(input)?;
|
||||
let mut clauses = Vec::new();
|
||||
let mut index = 0usize;
|
||||
while index < tokens.len() {
|
||||
let token = &tokens[index];
|
||||
if token.eq_ignore_ascii_case("AND") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if token.eq_ignore_ascii_case("OR") {
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"file log search supports AND and negation, not OR".into(),
|
||||
));
|
||||
}
|
||||
let (negated, term) = token
|
||||
.strip_prefix('!')
|
||||
.map_or((false, token.as_str()), |value| (true, value));
|
||||
if term.is_empty() {
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid file log search syntax".into(),
|
||||
));
|
||||
}
|
||||
if let Some((field, inline_value)) = term.split_once(':') {
|
||||
if field.is_empty()
|
||||
|| field.len() > 128
|
||||
|| !field
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid file log search field".into(),
|
||||
));
|
||||
}
|
||||
let value = if inline_value.is_empty() {
|
||||
index += 1;
|
||||
let Some(next) = tokens.get(index) else {
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"missing file log search value".into(),
|
||||
));
|
||||
};
|
||||
if next.eq_ignore_ascii_case("AND") || next.eq_ignore_ascii_case("OR") {
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"missing file log search value".into(),
|
||||
));
|
||||
}
|
||||
next.clone()
|
||||
} else {
|
||||
inline_value.to_owned()
|
||||
};
|
||||
if value.is_empty() || value.len() > 256 {
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid file log search value".into(),
|
||||
));
|
||||
}
|
||||
clauses.push(FileLogSearchClause::Field {
|
||||
field: field.to_ascii_lowercase(),
|
||||
value: value.to_ascii_lowercase(),
|
||||
negated,
|
||||
});
|
||||
} else {
|
||||
clauses.push(FileLogSearchClause::Any {
|
||||
value: term.to_ascii_lowercase(),
|
||||
negated,
|
||||
});
|
||||
}
|
||||
if clauses.len() > 32 {
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"too many file log search clauses".into(),
|
||||
));
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
Ok(clauses)
|
||||
}
|
||||
|
||||
fn file_log_field_alias(field: &str) -> &str {
|
||||
match field {
|
||||
"req" | "request" => "method",
|
||||
"ip" | "client" => "client_ip",
|
||||
"source" => "_source",
|
||||
"time" => "timestamp",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn file_log_field<'a>(value: &'a Value, field: &str) -> Option<&'a Value> {
|
||||
let mut current = value;
|
||||
for part in file_log_field_alias(field).split('.') {
|
||||
let object = current.as_object()?;
|
||||
current = object.get(part).or_else(|| {
|
||||
object
|
||||
.iter()
|
||||
.find(|(key, _)| key.eq_ignore_ascii_case(part))
|
||||
.map(|(_, value)| value)
|
||||
})?;
|
||||
}
|
||||
Some(current)
|
||||
}
|
||||
|
||||
fn wildcard_match_ci(candidate: &str, pattern: &str) -> bool {
|
||||
if !pattern.contains('*') {
|
||||
return candidate.contains(pattern);
|
||||
}
|
||||
if pattern == "*" {
|
||||
return true;
|
||||
}
|
||||
let starts_wild = pattern.starts_with('*');
|
||||
let ends_wild = pattern.ends_with('*');
|
||||
let parts = pattern
|
||||
.split('*')
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if parts.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let mut cursor = 0usize;
|
||||
for (index, part) in parts.iter().enumerate() {
|
||||
let Some(found) = candidate[cursor..].find(part) else {
|
||||
return false;
|
||||
};
|
||||
if index == 0 && !starts_wild && found != 0 {
|
||||
return false;
|
||||
}
|
||||
cursor += found + part.len();
|
||||
}
|
||||
ends_wild || cursor == candidate.len()
|
||||
}
|
||||
|
||||
fn numeric_match(candidate: f64, pattern: &str) -> Option<bool> {
|
||||
if let Some(raw) = pattern.strip_prefix(">=") {
|
||||
return raw
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.map(|expected| candidate >= expected);
|
||||
}
|
||||
if let Some(raw) = pattern.strip_prefix("<=") {
|
||||
return raw
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.map(|expected| candidate <= expected);
|
||||
}
|
||||
if let Some(raw) = pattern.strip_prefix('>') {
|
||||
return raw.parse::<f64>().ok().map(|expected| candidate > expected);
|
||||
}
|
||||
if let Some(raw) = pattern.strip_prefix('<') {
|
||||
return raw.parse::<f64>().ok().map(|expected| candidate < expected);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn file_log_value_matches(value: &Value, pattern: &str) -> bool {
|
||||
match value {
|
||||
Value::Null => pattern == "null",
|
||||
Value::Bool(value) => wildcard_match_ci(if *value { "true" } else { "false" }, pattern),
|
||||
Value::Number(value) => {
|
||||
let rendered = value.to_string();
|
||||
if pattern.len() == 3 && pattern.ends_with("xx") {
|
||||
return rendered.as_bytes().first() == pattern.as_bytes().first();
|
||||
}
|
||||
numeric_match(value.as_f64().unwrap_or_default(), pattern)
|
||||
.unwrap_or_else(|| wildcard_match_ci(&rendered, pattern))
|
||||
}
|
||||
Value::String(value) => {
|
||||
let candidate = value.to_ascii_lowercase();
|
||||
if pattern.ends_with("xx") && pattern.len() == 3 && candidate.len() == 3 {
|
||||
return candidate.as_bytes().first() == pattern.as_bytes().first();
|
||||
}
|
||||
if let Ok(candidate_number) = candidate.parse::<f64>() {
|
||||
if let Some(result) = numeric_match(candidate_number, pattern) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
wildcard_match_ci(&candidate, pattern)
|
||||
}
|
||||
Value::Array(values) => values
|
||||
.iter()
|
||||
.any(|value| file_log_value_matches(value, pattern)),
|
||||
Value::Object(_) => {
|
||||
let mut rendered = String::new();
|
||||
append_search_values(value, &mut rendered);
|
||||
wildcard_match_ci(rendered.trim(), pattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn record_matches_search(value: &Value, clauses: &[FileLogSearchClause]) -> bool {
|
||||
clauses.iter().all(|clause| {
|
||||
let (matched, negated) = match clause {
|
||||
FileLogSearchClause::Any {
|
||||
value: needle,
|
||||
negated,
|
||||
} => {
|
||||
let mut searchable = String::new();
|
||||
append_search_values(value, &mut searchable);
|
||||
(wildcard_match_ci(searchable.trim(), needle), *negated)
|
||||
}
|
||||
FileLogSearchClause::Field {
|
||||
field,
|
||||
value: needle,
|
||||
negated,
|
||||
} => (
|
||||
file_log_field(value, field)
|
||||
.is_some_and(|value| file_log_value_matches(value, needle)),
|
||||
*negated,
|
||||
),
|
||||
};
|
||||
matched != negated
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_raw_field_key(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 128
|
||||
&& value
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
|
||||
}
|
||||
|
||||
fn parse_raw_error_record(line: &str) -> Value {
|
||||
let mut parts = line.split_whitespace();
|
||||
let Some(timestamp) = parts.next() else {
|
||||
return json!({"raw": line});
|
||||
};
|
||||
let Some(level) = parts.next() else {
|
||||
return json!({"raw": line});
|
||||
};
|
||||
let Some(target) = parts.next() else {
|
||||
return json!({"raw": line});
|
||||
};
|
||||
let rest = parts.collect::<Vec<_>>();
|
||||
let first_field = rest.iter().position(|token| {
|
||||
token
|
||||
.split_once('=')
|
||||
.is_some_and(|(key, _)| valid_raw_field_key(key))
|
||||
});
|
||||
let message_end = first_field.unwrap_or(rest.len());
|
||||
let mut object = serde_json::Map::new();
|
||||
object.insert("timestamp".into(), Value::String(timestamp.into()));
|
||||
object.insert("level".into(), Value::String(level.into()));
|
||||
object.insert("target".into(), Value::String(target.into()));
|
||||
if message_end > 0 {
|
||||
object.insert(
|
||||
"message".into(),
|
||||
Value::String(rest[..message_end].join(" ")),
|
||||
);
|
||||
}
|
||||
let mut index = message_end;
|
||||
while index < rest.len() {
|
||||
let Some((key, start)) = rest[index].split_once('=') else {
|
||||
index += 1;
|
||||
continue;
|
||||
};
|
||||
if !valid_raw_field_key(key) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let mut pieces = vec![start];
|
||||
index += 1;
|
||||
while index < rest.len()
|
||||
&& !rest[index]
|
||||
.split_once('=')
|
||||
.is_some_and(|(next_key, _)| valid_raw_field_key(next_key))
|
||||
{
|
||||
pieces.push(rest[index]);
|
||||
index += 1;
|
||||
}
|
||||
object.insert(key.into(), Value::String(pieces.join(" ")));
|
||||
}
|
||||
object.insert("raw".into(), Value::String(line.into()));
|
||||
Value::Object(object)
|
||||
}
|
||||
|
||||
fn collect_file_log_fields(value: &Value, output: &mut std::collections::BTreeSet<String>) {
|
||||
fn walk(prefix: &str, value: &Value, output: &mut std::collections::BTreeSet<String>) {
|
||||
if output.len() >= 64 {
|
||||
return;
|
||||
}
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
for (key, child) in object {
|
||||
if key == "_source_kind" || output.len() >= 64 {
|
||||
continue;
|
||||
}
|
||||
let path = if prefix.is_empty() {
|
||||
key.clone()
|
||||
} else {
|
||||
format!("{prefix}.{key}")
|
||||
};
|
||||
if child.is_object() {
|
||||
walk(&path, child, output);
|
||||
} else {
|
||||
output.insert(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ if !prefix.is_empty() => {
|
||||
output.insert(prefix.into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
walk("", value, output);
|
||||
}
|
||||
Reference in new issue
Block a user