use yaml_serde::Value;
use super::super::{
Fix, FixDisposition, FixPatch, LintRule, LintWarning, Severity, closest_match, err, info, key,
safe_fix, warning,
};
const VALID_STATUSES: &[&str] = &[
"stable",
"test",
"experimental",
"deprecated",
"unsupported",
];
const VALID_LEVELS: &[&str] = &["informational", "low", "medium", "high", "critical"];
fn is_valid_date(s: &str) -> bool {
if s.len() != 10 {
return false;
}
let bytes = s.as_bytes();
if bytes[4] != b'-' || bytes[7] != b'-' {
return false;
}
let year_ok = bytes[0..4].iter().all(|b| b.is_ascii_digit());
let year: u16 = s[0..4].parse().unwrap_or(0);
let month: u8 = s[5..7].parse().unwrap_or(0);
let day: u8 = s[8..10].parse().unwrap_or(0);
if !year_ok || !(1..=12).contains(&month) || day == 0 {
return false;
}
let is_leap = (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400);
let max_day = match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if is_leap {
29
} else {
28
}
}
_ => return false,
};
day <= max_day
}
fn extract_date_string(raw: &Value) -> Option<String> {
raw.as_str().map(|s| s.to_string()).or_else(|| {
yaml_serde::to_string(raw)
.ok()
.map(|s| s.trim().to_string())
})
}
pub(crate) fn is_valid_uuid(s: &str) -> bool {
if s.len() != 36 {
return false;
}
let parts: Vec<&str> = s.split('-').collect();
if parts.len() != 5 {
return false;
}
let expected_lens = [8, 4, 4, 4, 12];
parts
.iter()
.zip(expected_lens.iter())
.all(|(part, &len)| part.len() == len && part.chars().all(|c| c.is_ascii_hexdigit()))
}
pub(crate) fn lint_shared(m: &yaml_serde::Mapping, warnings: &mut Vec<LintWarning>) {
match super::super::get_str(m, "title") {
None => warnings.push(err(
LintRule::MissingTitle,
"missing required field 'title'",
"/title",
)),
Some(t) if t.trim().is_empty() => {
warnings.push(err(
LintRule::EmptyTitle,
"title must not be empty",
"/title",
));
}
Some(t) if t.len() > 256 => {
warnings.push(warning(
LintRule::TitleTooLong,
format!("title is {} characters, maximum is 256", t.len()),
"/title",
));
}
_ => {}
}
if let Some(id) = super::super::get_str(m, "id")
&& !is_valid_uuid(id)
{
warnings.push(warning(
LintRule::InvalidId,
format!("id \"{id}\" is not a valid UUID"),
"/id",
));
}
if let Some(status) = super::super::get_str(m, "status")
&& !VALID_STATUSES.contains(&status)
{
let fix = closest_match(status, VALID_STATUSES, 3).map(|closest| Fix {
title: format!("replace '{status}' with '{closest}'"),
disposition: FixDisposition::Safe,
patches: vec![FixPatch::ReplaceValue {
path: "/status".into(),
new_value: closest.into(),
}],
});
warnings.push(LintWarning {
rule: LintRule::InvalidStatus,
severity: Severity::Error,
message: format!(
"invalid status \"{status}\", expected one of: {}",
VALID_STATUSES.join(", ")
),
path: "/status".into(),
span: None,
fix,
});
}
if let Some(level) = super::super::get_str(m, "level")
&& !VALID_LEVELS.contains(&level)
{
let fix = closest_match(level, VALID_LEVELS, 3).map(|closest| Fix {
title: format!("replace '{level}' with '{closest}'"),
disposition: FixDisposition::Safe,
patches: vec![FixPatch::ReplaceValue {
path: "/level".into(),
new_value: closest.into(),
}],
});
warnings.push(LintWarning {
rule: LintRule::InvalidLevel,
severity: Severity::Error,
message: format!(
"invalid level \"{level}\", expected one of: {}",
VALID_LEVELS.join(", ")
),
path: "/level".into(),
span: None,
fix,
});
}
let date_string = m.get(key("date")).and_then(extract_date_string);
if let Some(d) = &date_string
&& !is_valid_date(d)
{
warnings.push(err(
LintRule::InvalidDate,
format!("invalid date \"{d}\", expected YYYY-MM-DD"),
"/date",
));
}
let modified_string = m.get(key("modified")).and_then(extract_date_string);
if let Some(d) = &modified_string
&& !is_valid_date(d)
{
warnings.push(err(
LintRule::InvalidModified,
format!("invalid modified date \"{d}\", expected YYYY-MM-DD"),
"/modified",
));
}
if let (Some(date_val), Some(mod_val)) = (&date_string, &modified_string)
&& is_valid_date(date_val)
&& is_valid_date(mod_val)
&& mod_val.as_str() < date_val.as_str()
{
warnings.push(warning(
LintRule::ModifiedBeforeDate,
format!("modified date \"{mod_val}\" is before creation date \"{date_val}\""),
"/modified",
));
}
if !m.contains_key(key("description")) {
warnings.push(info(
LintRule::MissingDescription,
"missing recommended field 'description'",
"/description",
));
}
if !m.contains_key(key("author")) {
warnings.push(info(
LintRule::MissingAuthor,
"missing recommended field 'author'",
"/author",
));
}
if let Some(desc) = super::super::get_str(m, "description")
&& desc.len() > 65535
{
warnings.push(warning(
LintRule::DescriptionTooLong,
format!("description is {} characters, maximum is 65535", desc.len()),
"/description",
));
}
if let Some(name) = super::super::get_str(m, "name")
&& name.len() > 256
{
warnings.push(warning(
LintRule::NameTooLong,
format!("name is {} characters, maximum is 256", name.len()),
"/name",
));
}
if let Some(tax) = super::super::get_str(m, "taxonomy")
&& tax.len() > 256
{
warnings.push(warning(
LintRule::TaxonomyTooLong,
format!("taxonomy is {} characters, maximum is 256", tax.len()),
"/taxonomy",
));
}
for k in m.keys() {
if let Some(ks) = k.as_str()
&& ks != ks.to_ascii_lowercase()
{
let lower = ks.to_ascii_lowercase();
let mut w = warning(
LintRule::NonLowercaseKey,
format!("key \"{ks}\" should be lowercase"),
format!("/{ks}"),
);
w.fix = safe_fix(
format!("rename '{ks}' to '{lower}'"),
vec![FixPatch::ReplaceKey {
path: format!("/{ks}"),
new_key: lower,
}],
);
warnings.push(w);
}
}
}
#[cfg(test)]
mod tests {
use super::super::super::{Fix, LintRule, LintWarning, Severity, lint_yaml_value};
use super::*;
fn yaml_value(yaml: &str) -> yaml_serde::Value {
yaml_serde::from_str(yaml).unwrap()
}
fn lint(yaml: &str) -> Vec<LintWarning> {
lint_yaml_value(&yaml_value(yaml))
}
fn has_rule(warnings: &[LintWarning], rule: LintRule) -> bool {
warnings.iter().any(|w| w.rule == rule)
}
fn has_no_rule(warnings: &[LintWarning], rule: LintRule) -> bool {
!has_rule(warnings, rule)
}
fn find_fix(warnings: &[LintWarning], rule: LintRule) -> Option<&Fix> {
warnings
.iter()
.find(|w| w.rule == rule)
.and_then(|w| w.fix.as_ref())
}
fn fix_summary(fix: &Fix) -> String {
use std::fmt::Write;
let mut s = String::new();
writeln!(s, "title: {}", fix.title).unwrap();
writeln!(s, "disposition: {:?}", fix.disposition).unwrap();
for (i, p) in fix.patches.iter().enumerate() {
match p {
super::super::super::FixPatch::ReplaceValue { path, new_value } => {
writeln!(s, "patch[{i}]: ReplaceValue {path} -> {new_value}").unwrap();
}
super::super::super::FixPatch::ReplaceKey { path, new_key } => {
writeln!(s, "patch[{i}]: ReplaceKey {path} -> {new_key}").unwrap();
}
super::super::super::FixPatch::Remove { path } => {
writeln!(s, "patch[{i}]: Remove {path}").unwrap();
}
}
}
s
}
#[test]
fn missing_title() {
let w = lint(
r#"
logsource:
category: test
detection:
selection:
field: value
condition: selection
"#,
);
assert!(has_rule(&w, LintRule::MissingTitle));
}
#[test]
fn empty_title() {
let w = lint(
r#"
title: ''
logsource:
category: test
detection:
selection:
field: value
condition: selection
level: medium
"#,
);
assert!(has_rule(&w, LintRule::EmptyTitle));
}
#[test]
fn title_too_long() {
let long_title = "a".repeat(257);
let yaml = format!(
r#"
title: '{long_title}'
logsource:
category: test
detection:
selection:
field: value
condition: selection
"#
);
let w = lint(&yaml);
assert!(has_rule(&w, LintRule::TitleTooLong));
}
#[test]
fn invalid_id() {
let w = lint(
r#"
title: Test
id: not-a-uuid
logsource:
category: test
detection:
selection:
field: value
condition: selection
"#,
);
assert!(has_rule(&w, LintRule::InvalidId));
}
#[test]
fn valid_id_no_warning() {
let w = lint(
r#"
title: Test
id: 929a690e-bef0-4204-a928-ef5e620d6fcc
logsource:
category: test
detection:
selection:
field: value
condition: selection
"#,
);
assert!(has_no_rule(&w, LintRule::InvalidId));
}
#[test]
fn invalid_status() {
let w = lint(
r#"
title: Test
status: invalid
logsource:
category: test
detection:
selection:
field: value
condition: selection
"#,
);
assert!(has_rule(&w, LintRule::InvalidStatus));
}
#[test]
fn invalid_level() {
let w = lint(
r#"
title: Test
level: important
logsource:
category: test
detection:
selection:
field: value
condition: selection
"#,
);
assert!(has_rule(&w, LintRule::InvalidLevel));
}
#[test]
fn invalid_date_format() {
let w = lint(
r#"
title: Test
date: 'Jan 2025'
logsource:
category: test
detection:
selection:
field: value
condition: selection
"#,
);
assert!(has_rule(&w, LintRule::InvalidDate));
}
#[test]
fn modified_before_date() {
let w = lint(
r#"
title: Test
date: '2025-06-15'
modified: '2025-06-10'
logsource:
category: test
detection:
selection:
field: value
condition: selection
"#,
);
assert!(has_rule(&w, LintRule::ModifiedBeforeDate));
}
#[test]
fn non_lowercase_key() {
let w = lint(
r#"
title: Test
Status: test
logsource:
category: test
detection:
selection:
field: value
condition: selection
"#,
);
assert!(has_rule(&w, LintRule::NonLowercaseKey));
}
#[test]
fn invalid_date_feb_30() {
assert!(!is_valid_date("2025-02-30"));
}
#[test]
fn invalid_date_apr_31() {
assert!(!is_valid_date("2025-04-31"));
}
#[test]
fn valid_date_feb_28() {
assert!(is_valid_date("2025-02-28"));
}
#[test]
fn valid_date_leap_year_feb_29() {
assert!(is_valid_date("2024-02-29"));
}
#[test]
fn invalid_date_non_leap_feb_29() {
assert!(!is_valid_date("2025-02-29"));
}
#[test]
fn missing_description_info() {
let w = lint(
r#"
title: Test
logsource:
category: test
detection:
selection:
field: value
condition: selection
level: medium
"#,
);
assert!(has_rule(&w, LintRule::MissingDescription));
let md = w
.iter()
.find(|w| w.rule == LintRule::MissingDescription)
.unwrap();
assert_eq!(md.severity, Severity::Info);
}
#[test]
fn has_description_no_info() {
let w = lint(
r#"
title: Test
description: A fine description
logsource:
category: test
detection:
selection:
field: value
condition: selection
level: medium
"#,
);
assert!(has_no_rule(&w, LintRule::MissingDescription));
}
#[test]
fn missing_author_info() {
let w = lint(
r#"
title: Test
logsource:
category: test
detection:
selection:
field: value
condition: selection
level: medium
"#,
);
assert!(has_rule(&w, LintRule::MissingAuthor));
let ma = w
.iter()
.find(|w| w.rule == LintRule::MissingAuthor)
.unwrap();
assert_eq!(ma.severity, Severity::Info);
}
#[test]
fn has_author_no_info() {
let w = lint(
r#"
title: Test
author: tester
logsource:
category: test
detection:
selection:
field: value
condition: selection
level: medium
"#,
);
assert!(has_no_rule(&w, LintRule::MissingAuthor));
}
#[test]
fn fix_invalid_status() {
let w = lint(
r#"
title: Test
status: expreimental
logsource:
category: test
detection:
sel:
field: value
condition: sel
"#,
);
let fix = find_fix(&w, LintRule::InvalidStatus).expect("should have fix");
insta::assert_snapshot!(fix_summary(fix), @r"
title: replace 'expreimental' with 'experimental'
disposition: Safe
patch[0]: ReplaceValue /status -> experimental
");
}
#[test]
fn fix_invalid_level() {
let w = lint(
r#"
title: Test
level: hgih
logsource:
category: test
detection:
sel:
field: value
condition: sel
"#,
);
let fix = find_fix(&w, LintRule::InvalidLevel).expect("should have fix");
insta::assert_snapshot!(fix_summary(fix), @r"
title: replace 'hgih' with 'high'
disposition: Safe
patch[0]: ReplaceValue /level -> high
");
}
#[test]
fn fix_non_lowercase_key() {
let w = lint(
r#"
title: Test
Status: test
logsource:
category: test
detection:
sel:
field: value
condition: sel
"#,
);
let fix = find_fix(&w, LintRule::NonLowercaseKey).expect("should have fix");
insta::assert_snapshot!(fix_summary(fix), @r"
title: rename 'Status' to 'status'
disposition: Safe
patch[0]: ReplaceKey /Status -> status
");
}
#[test]
fn no_fix_for_far_invalid_status() {
let w = lint(
r#"
title: Test
status: totallyinvalidxyz
logsource:
category: test
detection:
sel:
field: value
condition: sel
"#,
);
assert!(has_rule(&w, LintRule::InvalidStatus));
assert!(
find_fix(&w, LintRule::InvalidStatus).is_none(),
"no fix when edit distance is too large"
);
}
}