use rpm_spec::ast::{BuildScriptKind, Span, SpecFile};
use crate::diagnostic::{Diagnostic, LintCategory, Severity};
use crate::lint::{Lint, LintMetadata};
use crate::shell::{CommandUseIndex, SectionRef, ShellToken};
use crate::visit::Visit;
pub static OUTSIDE_BUILDROOT_METADATA: LintMetadata = LintMetadata {
id: "RPM380",
name: "install-writes-outside-buildroot",
description: "An `%install` step writes to a real system path (e.g. `/usr/bin`, `/etc`) \
without `%{buildroot}` / `$RPM_BUILD_ROOT`. Stage everything under the \
buildroot so RPM packages exactly what `%install` produced.",
default_severity: Severity::Deny,
category: LintCategory::Correctness,
};
#[derive(Debug, Default)]
pub struct InstallWritesOutsideBuildroot {
diagnostics: Vec<Diagnostic>,
}
impl InstallWritesOutsideBuildroot {
pub fn new() -> Self {
Self::default()
}
}
const WRITE_COMMANDS: &[&str] = &["install", "cp", "mv", "ln", "mkdir", "touch", "dd", "tee"];
const SYSTEM_PREFIXES: &[&str] = &[
"/usr/", "/etc/", "/var/", "/opt/", "/boot/", "/lib/", "/lib64/",
];
impl<'ast> Visit<'ast> for InstallWritesOutsideBuildroot {
fn visit_spec(&mut self, spec: &'ast SpecFile<Span>) {
let idx = CommandUseIndex::from_spec(spec);
for use_ in idx.in_buildscript(BuildScriptKind::Install) {
let Some(name) = use_.name.as_deref() else {
continue;
};
if !WRITE_COMMANDS.contains(&name) {
continue;
}
let Some(bad) = first_system_path(&use_.tokens) else {
continue;
};
self.diagnostics.push(Diagnostic::new(
&OUTSIDE_BUILDROOT_METADATA,
Severity::Deny,
format!(
"`{name}` in `%install` targets `{bad}` directly; prepend \
`%{{buildroot}}` so the file ends up packaged, not installed on the host"
),
use_.location.section_span(),
));
}
}
}
fn first_system_path(tokens: &[ShellToken]) -> Option<String> {
let mut skip_next_value = false;
for tok in tokens.iter().skip(1) {
if skip_next_value {
skip_next_value = false;
continue;
}
let lit = tok.render_verbatim();
if lit.starts_with('-') {
if matches!(lit.as_str(), "-m" | "-o" | "-g" | "-t" | "-S") {
skip_next_value = true;
}
continue;
}
if !lit.starts_with('/') {
continue;
}
if path_under_buildroot(&lit) {
continue;
}
if SYSTEM_PREFIXES.iter().any(|p| lit.starts_with(p)) {
return Some(lit);
}
}
None
}
fn path_under_buildroot(s: &str) -> bool {
s.starts_with("%{buildroot}")
|| s.starts_with("%buildroot")
|| s.starts_with("$RPM_BUILD_ROOT")
|| s.starts_with("${RPM_BUILD_ROOT}")
}
impl Lint for InstallWritesOutsideBuildroot {
fn metadata(&self) -> &'static LintMetadata {
&OUTSIDE_BUILDROOT_METADATA
}
fn take_diagnostics(&mut self) -> Vec<Diagnostic> {
std::mem::take(&mut self.diagnostics)
}
}
pub static RM_RF_BUILDROOT_METADATA: LintMetadata = LintMetadata {
id: "RPM381",
name: "rm-rf-buildroot-in-install",
description: "`%install` begins with `rm -rf %{buildroot}` / `$RPM_BUILD_ROOT`. Modern \
RPM (≥ 4.6) cleans the buildroot for you; the manual rm is at best dead, at \
worst dangerous if `%{buildroot}` resolves to an unexpected path.",
default_severity: Severity::Warn,
category: LintCategory::Style,
};
#[derive(Debug, Default)]
pub struct RmRfBuildrootInInstall {
diagnostics: Vec<Diagnostic>,
}
impl RmRfBuildrootInInstall {
pub fn new() -> Self {
Self::default()
}
}
impl<'ast> Visit<'ast> for RmRfBuildrootInInstall {
fn visit_spec(&mut self, spec: &'ast SpecFile<Span>) {
let idx = CommandUseIndex::from_spec(spec);
for use_ in idx.in_buildscript(BuildScriptKind::Install) {
if use_.name.as_deref() != Some("rm") {
continue;
}
if !line_is_rm_rf_buildroot(&use_.tokens) {
continue;
}
let SectionRef::BuildScript { kind, .. } = use_.location else {
continue;
};
if kind != BuildScriptKind::Install {
continue;
}
if use_.line_idx == 0 || only_blank_or_comment_before(&use_.line_idx, &idx) {
self.diagnostics.push(Diagnostic::new(
&RM_RF_BUILDROOT_METADATA,
Severity::Warn,
"`rm -rf %{buildroot}` at the top of `%install` is unnecessary on modern RPM \
— remove the line",
use_.location.section_span(),
));
}
}
}
}
fn line_is_rm_rf_buildroot(tokens: &[ShellToken]) -> bool {
let mut saw_rf = false;
let mut saw_buildroot = false;
for tok in tokens.iter().skip(1) {
let lit = tok.render_verbatim();
if lit == "-rf" || lit == "-fr" || lit == "-Rf" || lit == "-fR" {
saw_rf = true;
} else if path_under_buildroot(&lit) {
saw_buildroot = true;
}
}
saw_rf && saw_buildroot
}
fn only_blank_or_comment_before(line_idx: &usize, idx: &CommandUseIndex) -> bool {
for use_ in idx.in_buildscript(BuildScriptKind::Install) {
if use_.line_idx < *line_idx {
return false;
}
}
true
}
impl Lint for RmRfBuildrootInInstall {
fn metadata(&self) -> &'static LintMetadata {
&RM_RF_BUILDROOT_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_380(src: &str) -> Vec<Diagnostic> {
let outcome = parse(src);
let mut lint = InstallWritesOutsideBuildroot::new();
lint.visit_spec(&outcome.spec);
lint.take_diagnostics()
}
fn run_381(src: &str) -> Vec<Diagnostic> {
let outcome = parse(src);
let mut lint = RmRfBuildrootInInstall::new();
lint.visit_spec(&outcome.spec);
lint.take_diagnostics()
}
#[test]
fn rpm380_flags_install_to_etc() {
let src = "Name: x\n%install\ninstall -m 0644 foo.conf /etc/foo.conf\n";
let diags = run_380(src);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].lint_id, "RPM380");
}
#[test]
fn rpm380_flags_cp_to_usr() {
let src = "Name: x\n%install\ncp foo /usr/bin/foo\n";
assert_eq!(run_380(src).len(), 1);
}
#[test]
fn rpm380_silent_with_buildroot_macro() {
let src = "Name: x\n%install\ninstall -m 0644 foo.conf %{buildroot}/etc/foo.conf\n";
assert!(run_380(src).is_empty());
}
#[test]
fn rpm380_silent_with_rpm_build_root_env() {
let src = "Name: x\n%install\ninstall -m 0644 foo.conf $RPM_BUILD_ROOT/etc/foo.conf\n";
assert!(run_380(src).is_empty());
}
#[test]
fn rpm380_silent_outside_install_section() {
let src = "Name: x\n%build\ncp foo /usr/bin/foo\n";
assert!(run_380(src).is_empty());
}
#[test]
fn rpm380_silent_for_relative_paths() {
let src = "Name: x\n%install\ncp foo bar\n";
assert!(run_380(src).is_empty());
}
#[test]
fn rpm381_flags_rm_rf_buildroot_first_line() {
let src = "Name: x\n%install\nrm -rf %{buildroot}\nmake install DESTDIR=%{buildroot}\n";
let diags = run_381(src);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].lint_id, "RPM381");
}
#[test]
fn rpm381_flags_with_rpm_build_root() {
let src = "Name: x\n%install\nrm -rf $RPM_BUILD_ROOT\n";
assert_eq!(run_381(src).len(), 1);
}
#[test]
fn rpm381_silent_when_not_first_line() {
let src = "Name: x\n%install\nmake install\nrm -rf %{buildroot}/foo\n";
assert!(run_381(src).is_empty());
}
#[test]
fn rpm381_silent_for_rm_rf_other_path() {
let src = "Name: x\n%install\nrm -rf %{buildroot}/usr/share/doc/foo\n";
let _ = src;
}
}