use rpm_spec::ast::{Span, SpecFile};
use crate::diagnostic::{Diagnostic, LintCategory, Severity};
use crate::lint::{Lint, LintMetadata};
use crate::shell::{CommandUseIndex, SectionRef};
use crate::visit::Visit;
pub static DIRECT_SYSTEMCTL_METADATA: LintMetadata = LintMetadata {
id: "RPM342",
name: "direct-systemctl-in-scriptlet",
description: "A scriptlet invokes `systemctl` directly. Use the distro-provided helpers \
(`%systemd_post` / `%service_add_post` etc.) so the unit lifecycle is \
managed by macros that handle non-systemd targets, chroots, and image \
builds.",
default_severity: Severity::Warn,
category: LintCategory::Packaging,
};
#[derive(Debug, Default)]
pub struct DirectSystemctlInScriptlet {
diagnostics: Vec<Diagnostic>,
}
impl DirectSystemctlInScriptlet {
pub fn new() -> Self {
Self::default()
}
}
const MUTATING_SUBCOMMANDS: &[&str] = &[
"enable",
"disable",
"start",
"stop",
"restart",
"reload",
"try-restart",
"reload-or-restart",
"preset",
"preset-all",
"mask",
"unmask",
"daemon-reload",
"daemon-reexec",
];
impl<'ast> Visit<'ast> for DirectSystemctlInScriptlet {
fn visit_spec(&mut self, spec: &'ast SpecFile<Span>) {
let idx = CommandUseIndex::from_spec(spec);
for use_ in idx.find("systemctl") {
if !matches!(use_.location, SectionRef::Scriptlet { .. }) {
continue;
}
let Some(subcmd) = first_subcommand(&use_.tokens) else {
continue;
};
if !MUTATING_SUBCOMMANDS.contains(&subcmd.as_str()) {
continue;
}
self.diagnostics.push(Diagnostic::new(
&DIRECT_SYSTEMCTL_METADATA,
Severity::Warn,
format!(
"scriptlet calls `systemctl {subcmd}` directly; switch to the distro's \
`%systemd_*` / `%service_*` macro family"
),
use_.location.section_span(),
));
}
}
}
fn first_subcommand(tokens: &[crate::shell::ShellToken]) -> Option<String> {
for tok in tokens.iter().skip(1) {
let lit = tok.literal_str()?;
if lit.starts_with('-') {
continue;
}
return Some(lit);
}
None
}
impl Lint for DirectSystemctlInScriptlet {
fn metadata(&self) -> &'static LintMetadata {
&DIRECT_SYSTEMCTL_METADATA
}
fn take_diagnostics(&mut self) -> Vec<Diagnostic> {
std::mem::take(&mut self.diagnostics)
}
}
pub static STATE_OUTSIDE_METADATA: LintMetadata = LintMetadata {
id: "RPM349",
name: "scriptlet-state-outside-rpm-state",
description: "Scriptlet writes scratch state under `/tmp` or `/var/tmp`. Those races with \
parallel transactions and leak on abort; use `$RPM_STATE_DIR` or \
`/var/lib/rpm-state/<pkg>` instead.",
default_severity: Severity::Warn,
category: LintCategory::Correctness,
};
#[derive(Debug, Default)]
pub struct ScriptletStateOutsideRpmState {
diagnostics: Vec<Diagnostic>,
}
impl ScriptletStateOutsideRpmState {
pub fn new() -> Self {
Self::default()
}
}
impl<'ast> Visit<'ast> for ScriptletStateOutsideRpmState {
fn visit_spec(&mut self, spec: &'ast SpecFile<Span>) {
let idx = CommandUseIndex::from_spec(spec);
for use_ in idx.all() {
if !matches!(use_.location, SectionRef::Scriptlet { .. }) {
continue;
}
let Some(name) = use_.name.as_deref() else {
continue;
};
if !WRITE_LIKE_COMMANDS.contains(&name) {
continue;
}
let Some(bad) = first_arg_under_tmp(&use_.tokens) else {
continue;
};
self.diagnostics.push(Diagnostic::new(
&STATE_OUTSIDE_METADATA,
Severity::Warn,
format!(
"scriptlet writes scratch state at `{bad}`; use `$RPM_STATE_DIR` or \
`/var/lib/rpm-state/<pkg>` for state shared between scriptlet phases"
),
use_.location.section_span(),
));
}
}
}
const WRITE_LIKE_COMMANDS: &[&str] = &[
"touch", "cp", "mv", "install", "ln", "mkdir", "tee", "dd", "echo",
];
fn first_arg_under_tmp(tokens: &[crate::shell::ShellToken]) -> Option<String> {
for tok in tokens.iter().skip(1) {
let lit = tok.render_verbatim();
if lit.starts_with("/tmp/") || lit == "/tmp" {
return Some(lit);
}
if lit.starts_with("/var/tmp/") || lit == "/var/tmp" {
return Some(lit);
}
}
None
}
impl Lint for ScriptletStateOutsideRpmState {
fn metadata(&self) -> &'static LintMetadata {
&STATE_OUTSIDE_METADATA
}
fn take_diagnostics(&mut self) -> Vec<Diagnostic> {
std::mem::take(&mut self.diagnostics)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::parse;
fn run_342(src: &str) -> Vec<Diagnostic> {
let outcome = parse(src);
let mut lint = DirectSystemctlInScriptlet::new();
lint.visit_spec(&outcome.spec);
lint.take_diagnostics()
}
fn run_349(src: &str) -> Vec<Diagnostic> {
let outcome = parse(src);
let mut lint = ScriptletStateOutsideRpmState::new();
lint.visit_spec(&outcome.spec);
lint.take_diagnostics()
}
#[test]
fn rpm342_flags_systemctl_enable() {
let src = "Name: x\n%post\nsystemctl enable foo.service\nexit 0\n";
let diags = run_342(src);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].lint_id, "RPM342");
assert!(diags[0].message.contains("enable"));
}
#[test]
fn rpm342_flags_systemctl_restart() {
let src = "Name: x\n%post\nsystemctl restart foo\nexit 0\n";
assert_eq!(run_342(src).len(), 1);
}
#[test]
fn rpm342_silent_for_systemctl_status() {
let src = "Name: x\n%post\nsystemctl status foo\nexit 0\n";
assert!(run_342(src).is_empty());
}
#[test]
fn rpm342_silent_in_install_section() {
let src = "Name: x\n%install\nsystemctl daemon-reload\n";
assert!(run_342(src).is_empty());
}
#[test]
fn rpm342_flags_with_flag_argument() {
let src = "Name: x\n%post\nsystemctl --now enable foo.service\nexit 0\n";
assert_eq!(run_342(src).len(), 1);
}
#[test]
fn rpm349_flags_touch_in_tmp() {
let src = "Name: x\n%post\ntouch /tmp/foo.flag\nexit 0\n";
let diags = run_349(src);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].lint_id, "RPM349");
}
#[test]
fn rpm349_flags_mkdir_in_var_tmp() {
let src = "Name: x\n%pre\nmkdir /var/tmp/scratch\nexit 0\n";
assert_eq!(run_349(src).len(), 1);
}
#[test]
fn rpm349_silent_for_read_in_tmp() {
let src = "Name: x\n%post\ncat /tmp/seed\nexit 0\n";
assert!(run_349(src).is_empty());
}
#[test]
fn rpm349_silent_in_install_section() {
let src = "Name: x\n%install\ntouch /tmp/foo\n";
assert!(run_349(src).is_empty());
}
#[test]
fn rpm349_silent_for_rpm_state_dir() {
let src = "Name: x\n%post\ntouch $RPM_STATE_DIR/foo\nexit 0\n";
assert!(run_349(src).is_empty());
}
}