use rpm_spec::ast::{Scriptlet, Span, SpecFile, Text, TextSegment};
use crate::diagnostic::{Diagnostic, LintCategory, Severity};
use crate::lint::{Lint, LintMetadata};
use crate::shell::for_each_scriptlet;
use crate::visit::Visit;
pub static EXIT_GUARDED_METADATA: LintMetadata = LintMetadata {
id: "RPM340",
name: "scriptlet-exit-not-guaranteed-zero",
description: "A scriptlet's last command can fail with no explicit exit guard. RPM aborts \
the transaction on non-zero exit, leaving the system half-installed. Add \
`|| :` / `|| true` / `exit 0`, or use `set +e`.",
default_severity: Severity::Warn,
category: LintCategory::Correctness,
};
#[derive(Debug, Default)]
pub struct ScriptletExitNotGuaranteedZero {
diagnostics: Vec<Diagnostic>,
}
impl ScriptletExitNotGuaranteedZero {
pub fn new() -> Self {
Self::default()
}
}
impl<'ast> Visit<'ast> for ScriptletExitNotGuaranteedZero {
fn visit_spec(&mut self, spec: &'ast SpecFile<Span>) {
for_each_scriptlet(spec, |s| {
if scriptlet_is_lua(s) {
return;
}
let Some(last_idx) = last_meaningful_line(&s.body.lines) else {
return;
};
let last = &s.body.lines[last_idx];
if line_guarantees_zero_exit(last) {
return;
}
if has_set_minus_e_active_at_last(&s.body.lines, last_idx) {
self.diagnostics.push(diag_340(s.data));
return;
}
if line_is_potentially_fallible(last) {
self.diagnostics.push(diag_340(s.data));
}
});
}
}
fn diag_340(span: Span) -> Diagnostic {
Diagnostic::new(
&EXIT_GUARDED_METADATA,
Severity::Warn,
"scriptlet's last command has no exit-zero guard; append `|| :`, `|| true`, or \
`exit 0` so RPM does not abort the transaction on failure",
span,
)
}
fn scriptlet_is_lua(s: &Scriptlet<Span>) -> bool {
matches!(s.interp, Some(rpm_spec::ast::Interpreter::Lua))
}
fn last_meaningful_line(lines: &[Text]) -> Option<usize> {
for (i, line) in lines.iter().enumerate().rev() {
if !is_blank_or_comment(line) {
return Some(i);
}
}
None
}
fn is_blank_or_comment(line: &Text) -> bool {
if line.segments.is_empty() {
return true;
}
let Some(lit) = line.literal_str() else {
return false;
};
let trimmed = lit.trim();
trimmed.is_empty() || trimmed.starts_with('#')
}
fn line_guarantees_zero_exit(line: &Text) -> bool {
let Some(lit) = line.literal_str() else {
return false;
};
let trimmed = lit.trim();
if trimmed == ":" || trimmed == "true" || trimmed == "exit 0" {
return true;
}
let collapsed: String = trimmed.split_whitespace().collect::<Vec<_>>().join(" ");
collapsed.ends_with("|| :")
|| collapsed.ends_with("|| true")
|| collapsed.ends_with("|| exit 0")
}
fn line_is_potentially_fallible(line: &Text) -> bool {
!is_blank_or_comment(line)
}
fn has_set_minus_e_active_at_last(lines: &[Text], last_idx: usize) -> bool {
let mut active = false;
for line in &lines[..=last_idx] {
let Some(lit) = line.literal_str() else {
continue;
};
let trimmed = lit.trim();
if trimmed == "set -e"
|| trimmed.starts_with("set -e ")
|| trimmed.starts_with("set -eu")
|| trimmed.starts_with("set -eo")
|| trimmed.starts_with("set -euo")
{
active = true;
} else if trimmed == "set +e" || trimmed.starts_with("set +e ") {
active = false;
}
}
active
}
impl Lint for ScriptletExitNotGuaranteedZero {
fn metadata(&self) -> &'static LintMetadata {
&EXIT_GUARDED_METADATA
}
fn take_diagnostics(&mut self) -> Vec<Diagnostic> {
std::mem::take(&mut self.diagnostics)
}
}
pub static UPGRADE_TEST_METADATA: LintMetadata = LintMetadata {
id: "RPM341",
name: "scriptlet-upgrade-test-eq-two",
description: "Scriptlet compares the install count `$1` to exactly `2` to detect an \
upgrade. Multilib and error recovery can push `$1` above `2`; use \
`[ $1 -gt 1 ]` instead.",
default_severity: Severity::Warn,
category: LintCategory::Correctness,
};
#[derive(Debug, Default)]
pub struct ScriptletUpgradeTestEqTwo {
diagnostics: Vec<Diagnostic>,
}
impl ScriptletUpgradeTestEqTwo {
pub fn new() -> Self {
Self::default()
}
}
impl<'ast> Visit<'ast> for ScriptletUpgradeTestEqTwo {
fn visit_spec(&mut self, spec: &'ast SpecFile<Span>) {
for_each_scriptlet(spec, |s| {
if scriptlet_is_lua(s) {
return;
}
if s.body.lines.iter().any(line_matches_eq_two) {
self.diagnostics.push(Diagnostic::new(
&UPGRADE_TEST_METADATA,
Severity::Warn,
"scriptlet compares `$1` to literal `2`; multilib/error recovery can make \
`$1 > 2` — use `[ $1 -gt 1 ]` to mean \"this is an upgrade\"",
s.data,
));
}
});
}
}
fn line_matches_eq_two(line: &Text) -> bool {
let mut lit = String::new();
for seg in &line.segments {
if let TextSegment::Literal(s) = seg {
lit.push_str(s);
}
}
let normalised: String = lit.split_whitespace().collect::<Vec<_>>().join(" ");
contains_dollar_one_eq_two(&normalised)
}
fn contains_dollar_one_eq_two(s: &str) -> bool {
let needles_after = [" = 2", " -eq 2", " == 2"];
for op in &needles_after {
for prefix in ["$1", "\"$1\""] {
let mut idx = 0;
while let Some(found) = s[idx..].find(prefix) {
let after_prefix_start = idx + found + prefix.len();
if s[after_prefix_start..].starts_with(op) {
let end_idx = after_prefix_start + op.len();
match s.as_bytes().get(end_idx) {
None => return true,
Some(b) if !b.is_ascii_digit() => return true,
Some(_) => {}
}
}
idx = after_prefix_start;
}
}
}
false
}
impl Lint for ScriptletUpgradeTestEqTwo {
fn metadata(&self) -> &'static LintMetadata {
&UPGRADE_TEST_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_340(src: &str) -> Vec<Diagnostic> {
let outcome = parse(src);
let mut lint = ScriptletExitNotGuaranteedZero::new();
lint.visit_spec(&outcome.spec);
lint.take_diagnostics()
}
fn run_341(src: &str) -> Vec<Diagnostic> {
let outcome = parse(src);
let mut lint = ScriptletUpgradeTestEqTwo::new();
lint.visit_spec(&outcome.spec);
lint.take_diagnostics()
}
#[test]
fn rpm340_flags_bare_failing_last_command() {
let src = "Name: x\n%post\nrm -f /tmp/foo.lock\n";
let diags = run_340(src);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].lint_id, "RPM340");
}
#[test]
fn rpm340_silent_with_exit_zero() {
let src = "Name: x\n%post\nsystemctl restart foo\nexit 0\n";
assert!(run_340(src).is_empty());
}
#[test]
fn rpm340_silent_with_or_colon_guard() {
let src = "Name: x\n%post\nsystemctl restart foo || :\n";
assert!(run_340(src).is_empty());
}
#[test]
fn rpm340_silent_with_or_true_guard() {
let src = "Name: x\n%post\nsystemctl restart foo || true\n";
assert!(run_340(src).is_empty());
}
#[test]
fn rpm340_ignores_trailing_blank_lines_and_comments() {
let src = "Name: x\n%post\nsystemctl restart foo\nexit 0\n\n# done\n";
assert!(run_340(src).is_empty());
}
#[test]
fn rpm340_silent_for_lua_interpreter() {
let src = "Name: x\n%post -p <lua>\nprint(\"hi\")\n";
assert!(run_340(src).is_empty());
}
#[test]
fn rpm341_flags_dollar_one_eq_two_with_test() {
let src = "Name: x\n%post\nif [ \"$1\" = 2 ]; then echo up; fi\nexit 0\n";
let diags = run_341(src);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].lint_id, "RPM341");
}
#[test]
fn rpm341_flags_dollar_one_dash_eq_two() {
let src = "Name: x\n%postun\nif [ $1 -eq 2 ]; then echo up; fi\nexit 0\n";
assert_eq!(run_341(src).len(), 1);
}
#[test]
fn rpm341_flags_double_eq_two() {
let src = "Name: x\n%post\nif [[ $1 == 2 ]]; then echo up; fi\nexit 0\n";
assert_eq!(run_341(src).len(), 1);
}
#[test]
fn rpm341_silent_with_gt_one() {
let src = "Name: x\n%post\nif [ $1 -gt 1 ]; then echo up; fi\nexit 0\n";
assert!(run_341(src).is_empty());
}
#[test]
fn rpm341_silent_for_eq_20_boundary() {
let src = "Name: x\n%post\nif [ $1 -eq 20 ]; then echo many; fi\nexit 0\n";
assert!(run_341(src).is_empty());
}
#[test]
fn rpm341_silent_for_lua() {
let src = "Name: x\n%post -p <lua>\nif $1 == 2 then end\n";
assert!(run_341(src).is_empty());
}
}