use rpm_spec::ast::{Span, Tag, TagValue};
use rpm_spec_profile::{Profile, ValidationMode};
use crate::diagnostic::{Diagnostic, LintCategory, Severity};
use crate::lint::{Lint, LintMetadata};
use crate::rules::util::{iter_packages, split_spdx_atoms};
use crate::visit::Visit;
pub static METADATA: LintMetadata = LintMetadata {
id: "RPM024",
name: "invalid-license",
description: "License: must name a license from the profile's allow-list.",
default_severity: Severity::Warn,
category: LintCategory::Packaging,
};
#[derive(Debug, Default)]
pub struct InvalidLicense {
diagnostics: Vec<Diagnostic>,
allowed: std::collections::BTreeSet<String>,
mode: ValidationMode,
}
impl InvalidLicense {
pub fn new() -> Self {
Self::default()
}
fn check_value(&mut self, value: &TagValue, span: Span) {
let TagValue::Text(text) = value else { return };
let Some(literal) = text.literal_str() else {
return;
};
for atom in split_spdx_atoms(literal) {
if atom.is_empty() {
continue;
}
if !self.allowed.contains(atom) {
let lowered = atom.to_ascii_lowercase();
let suggestion = self
.allowed
.iter()
.find(|known| known.to_ascii_lowercase() == lowered);
let msg = match suggestion {
Some(canonical) => format!(
"license `{atom}` is not in the profile allow-list — did you mean `{canonical}`?"
),
None => format!(
"license `{atom}` is not in the profile allow-list (\
{n} entries); check `profile show --full` for the full set",
n = self.allowed.len()
),
};
self.diagnostics.push(Diagnostic::new(
&METADATA,
METADATA.default_severity,
msg,
span,
));
}
}
}
}
impl<'ast> Visit<'ast> for InvalidLicense {
fn visit_spec(&mut self, spec: &'ast rpm_spec::ast::SpecFile<Span>) {
if matches!(self.mode, ValidationMode::Off) {
return;
}
for pkg in iter_packages(spec) {
for item in pkg.items() {
if matches!(item.tag, Tag::License) {
self.check_value(&item.value, item.data);
}
}
}
}
}
impl Lint for InvalidLicense {
fn metadata(&self) -> &'static LintMetadata {
&METADATA
}
fn take_diagnostics(&mut self) -> Vec<Diagnostic> {
std::mem::take(&mut self.diagnostics)
}
fn set_profile(&mut self, profile: &Profile) {
self.allowed = profile.licenses.allowed.clone();
self.mode = profile.licenses.mode;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::parse;
fn run(src: &str, allowed: &[&str], mode: ValidationMode) -> Vec<Diagnostic> {
let outcome = parse(src);
let mut lint = InvalidLicense::new();
let mut profile = Profile::default();
profile.licenses.allowed = allowed.iter().map(|s| (*s).to_string()).collect();
profile.licenses.mode = mode;
lint.set_profile(&profile);
lint.visit_spec(&outcome.spec);
lint.take_diagnostics()
}
#[test]
fn silent_when_mode_is_off() {
let diags = run(
"Name: x\nLicense: WTFPL\n",
&["MIT", "GPL-2.0-or-later"],
ValidationMode::Off,
);
assert!(diags.is_empty(), "expected silence; got {diags:?}");
}
#[test]
fn allows_listed_license() {
let diags = run(
"Name: x\nLicense: MIT\n",
&["MIT", "GPL-2.0-or-later"],
ValidationMode::Warn,
);
assert!(diags.is_empty(), "MIT is allowed; got {diags:?}");
}
#[test]
fn flags_unknown_license() {
let diags = run(
"Name: x\nLicense: WTFPL\n",
&["MIT", "GPL-2.0-or-later"],
ValidationMode::Warn,
);
assert_eq!(diags.len(), 1, "expected one diagnostic; got {diags:?}");
assert_eq!(diags[0].lint_id, "RPM024");
assert!(
diags[0].message.contains("WTFPL"),
"message should name the bad license; got {}",
diags[0].message
);
}
#[test]
fn splits_spdx_or_and_with() {
let diags = run(
"Name: x\nLicense: WTFPL OR Apache-2.0 WITH Classpath-exception-2.0\n",
&["MIT", "Apache-2.0", "Classpath-exception-2.0"],
ValidationMode::Warn,
);
assert_eq!(diags.len(), 1, "only WTFPL is bad; got {diags:?}");
assert!(diags[0].message.contains("WTFPL"));
}
#[test]
fn handles_parens() {
let diags = run(
"Name: x\nLicense: (MIT OR Apache-2.0)\n",
&["MIT", "Apache-2.0"],
ValidationMode::Warn,
);
assert!(
diags.is_empty(),
"parenthesised group is fine; got {diags:?}"
);
}
#[test]
fn skips_macro_bearing_value() {
let diags = run(
"Name: x\nLicense: %{?dist_license}\n",
&["MIT"],
ValidationMode::Strict,
);
assert!(
diags.is_empty(),
"macro-bearing value must be skipped; got {diags:?}"
);
}
#[test]
fn case_insensitive_typo_hint() {
let diags = run("Name: x\nLicense: mit\n", &["MIT"], ValidationMode::Warn);
assert_eq!(diags.len(), 1);
assert!(
diags[0].message.contains("did you mean `MIT`"),
"case-mismatch should hint at canonical form; got {}",
diags[0].message
);
}
#[test]
fn checks_subpackages() {
let src = "\
Name: x
Version: 1
Release: 1
License: MIT
Summary: s
%description
b
%package devel
Summary: dev
License: WTFPL
%description devel
d
";
let diags = run(src, &["MIT"], ValidationMode::Warn);
assert_eq!(
diags.len(),
1,
"subpackage's bad license must be flagged; got {diags:?}"
);
assert!(diags[0].message.contains("WTFPL"));
}
#[test]
fn split_handles_word_boundary() {
let atoms = split_spdx_atoms("ORIGINAL");
assert_eq!(atoms, vec!["ORIGINAL"]);
}
}