use std::collections::BTreeSet;
use rpm_spec::ast::{ScriptletKind, Span, SpecFile, Tag};
use crate::diagnostic::{Diagnostic, LintCategory, Severity};
use crate::lint::{Lint, LintMetadata};
use crate::policy::PolicyRegistry;
use crate::rules::util::collect_top_level_dep_names;
use crate::shell::{CommandUseIndex, SectionRef};
use crate::visit::Visit;
use rpm_spec_profile::Profile;
pub static METADATA: LintMetadata = LintMetadata {
id: "RPM328",
name: "scriptlet-command-without-requires",
description: "A scriptlet invokes a runtime helper (`useradd`, `getent`, \
`update-alternatives`, ...) without declaring the providing package in \
`Requires:`. Minimal images abort the scriptlet with command-not-found.",
default_severity: Severity::Warn,
category: LintCategory::Correctness,
};
#[derive(Debug, Default)]
pub struct ScriptletCommandWithoutRequires {
diagnostics: Vec<Diagnostic>,
policy: PolicyRegistry,
}
impl ScriptletCommandWithoutRequires {
pub fn new() -> Self {
Self::default()
}
}
impl<'ast> Visit<'ast> for ScriptletCommandWithoutRequires {
fn visit_spec(&mut self, spec: &'ast SpecFile<Span>) {
if self.policy.scriptlet_required_deps.is_empty() {
return;
}
let declared = collect_top_level_dep_names(spec, |t| matches!(t, Tag::Requires));
let idx = CommandUseIndex::from_spec(spec);
let mut reported: BTreeSet<(usize, &'static str)> = BTreeSet::new();
for call in idx.all() {
let SectionRef::Scriptlet { kind, .. } = call.location else {
continue;
};
let Some(cmd) = call.name.as_deref() else {
continue;
};
let Some(&(_, req_atom)) = self
.policy
.scriptlet_required_deps
.iter()
.find(|(tool, _)| *tool == cmd)
else {
continue;
};
if declared.contains(req_atom) {
continue;
}
let key = (call.location.section_span().start_byte, req_atom);
if !reported.insert(key) {
continue;
}
let phase = scriptlet_qualifier(kind);
self.diagnostics.push(Diagnostic::new(
&METADATA,
Severity::Warn,
format!(
"scriptlet calls `{cmd}` in %{phase} but the spec does not declare \
`Requires({phase}): {req_atom}`; the scriptlet will abort on minimal \
images that don't pull the helper in transitively"
),
call.location.section_span(),
));
}
}
}
fn scriptlet_qualifier(kind: ScriptletKind) -> &'static str {
match kind {
ScriptletKind::Pre => "pre",
ScriptletKind::Post => "post",
ScriptletKind::Preun => "preun",
ScriptletKind::Postun => "postun",
ScriptletKind::Pretrans => "pretrans",
ScriptletKind::Posttrans => "posttrans",
ScriptletKind::Preuntrans => "preuntrans",
ScriptletKind::Postuntrans => "postuntrans",
_ => "pre",
}
}
impl Lint for ScriptletCommandWithoutRequires {
fn metadata(&self) -> &'static LintMetadata {
&METADATA
}
fn take_diagnostics(&mut self) -> Vec<Diagnostic> {
std::mem::take(&mut self.diagnostics)
}
fn applies_to_profile(&self, profile: &Profile) -> bool {
!PolicyRegistry::for_profile(profile)
.scriptlet_required_deps
.is_empty()
}
fn set_profile(&mut self, profile: &Profile) {
self.policy = PolicyRegistry::for_profile(profile);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::parse;
use rpm_spec_profile::{Family, Profile};
fn fedora_profile() -> Profile {
let mut p = Profile::default();
p.identity.family = Some(Family::Fedora);
p
}
fn run(src: &str) -> Vec<Diagnostic> {
let outcome = parse(src);
let mut lint = ScriptletCommandWithoutRequires::new();
lint.set_profile(&fedora_profile());
lint.visit_spec(&outcome.spec);
lint.take_diagnostics()
}
#[test]
fn flags_useradd_without_requires() {
let src = "Name: x\n%pre\nuseradd -r foo\nexit 0\n";
let diags = run(src);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].lint_id, "RPM328");
assert!(diags[0].message.contains("useradd"));
assert!(diags[0].message.contains("shadow-utils"));
assert!(
diags[0].message.contains("Requires(pre)"),
"should suggest the matching qualifier: {:?}",
diags[0].message
);
}
#[test]
fn silent_when_requires_declared() {
let src = "Name: x\nRequires(pre): shadow-utils\n%pre\nuseradd -r foo\nexit 0\n";
assert!(run(src).is_empty());
}
#[test]
fn silent_when_plain_requires_declared() {
let src = "Name: x\nRequires: shadow-utils\n%pre\nuseradd -r foo\nexit 0\n";
assert!(run(src).is_empty());
}
#[test]
fn silent_when_requires_has_qualifier_list() {
let src = "Name: x\nRequires(pre,post): shadow-utils\n%pre\nuseradd -r foo\nexit 0\n";
assert!(run(src).is_empty());
}
#[test]
fn flags_systemctl_in_post() {
let src = "Name: x\n%post\nsystemctl daemon-reload\nexit 0\n";
let diags = run(src);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("systemd"));
assert!(diags[0].message.contains("Requires(post)"));
}
#[test]
fn silent_in_buildscript() {
let src = "Name: x\n%install\nuseradd -r foo\n";
assert!(run(src).is_empty());
}
#[test]
fn deduplicates_repeated_calls_in_one_scriptlet() {
let src = "Name: x\n%post\nsystemctl daemon-reload\nsystemctl restart foo\nexit 0\n";
assert_eq!(run(src).len(), 1);
}
#[test]
fn emits_per_scriptlet_section() {
let src = "Name: x\n%pre\nuseradd -r foo\nexit 0\n%postun\nuserdel foo\nexit 0\n";
let diags = run(src);
assert_eq!(diags.len(), 1);
}
#[test]
fn emits_once_per_scriptlet_per_atom() {
let src = "Name: x\n%pre\nuseradd -r foo\ngroupadd -r foo\nexit 0\n";
assert_eq!(run(src).len(), 1);
}
#[test]
fn silent_for_unknown_command() {
let src = "Name: x\n%post\nfoo --bar\nexit 0\n";
assert!(run(src).is_empty());
}
#[test]
fn silent_on_generic_profile() {
let outcome = parse("Name: x\n%pre\nuseradd -r foo\n");
let mut lint = ScriptletCommandWithoutRequires::new();
lint.set_profile(&Profile::default());
lint.visit_spec(&outcome.spec);
assert!(lint.take_diagnostics().is_empty());
}
}