use rpm_spec::ast::{
MacroKind, PreambleContent, PreambleItem, Section, Span, SpecFile, SpecItem, Tag, TagValue,
Text, TextSegment,
};
use crate::diagnostic::{Diagnostic, LintCategory, Severity};
use crate::lint::{Lint, LintMetadata};
use crate::visit::Visit;
pub static METADATA: LintMetadata = LintMetadata {
id: "RPM404",
name: "macro-shell-expansion-in-metadata",
description: "An identity tag (`Version`, `Release`, `Source*`, `URL`, …) carries `%(shell)` \
or `%{lua:...}`. The expansion is evaluated at build time, so the resulting \
NVR / SRPM filename / changelog changes between builds — reproducibility \
breaks.",
default_severity: Severity::Warn,
category: LintCategory::Correctness,
};
#[derive(Debug, Default)]
pub struct MacroShellExpansionInMetadata {
diagnostics: Vec<Diagnostic>,
}
impl MacroShellExpansionInMetadata {
pub fn new() -> Self {
Self::default()
}
}
impl<'ast> Visit<'ast> for MacroShellExpansionInMetadata {
fn visit_spec(&mut self, spec: &'ast SpecFile<Span>) {
self.walk_top_items(&spec.items);
}
}
impl MacroShellExpansionInMetadata {
fn walk_top_items(&mut self, items: &[SpecItem<Span>]) {
for item in items {
match item {
SpecItem::Preamble(p) => self.check_item(p),
SpecItem::Section(boxed) => {
if let Section::Package { content, .. } = boxed.as_ref() {
self.walk_preamble_content(content);
}
}
SpecItem::Conditional(c) => {
for branch in &c.branches {
self.walk_top_items(&branch.body);
}
if let Some(els) = &c.otherwise {
self.walk_top_items(els);
}
}
_ => {}
}
}
}
fn walk_preamble_content(&mut self, items: &[PreambleContent<Span>]) {
for item in items {
match item {
PreambleContent::Item(p) => self.check_item(p),
PreambleContent::Conditional(c) => {
for branch in &c.branches {
self.walk_preamble_content(&branch.body);
}
if let Some(els) = &c.otherwise {
self.walk_preamble_content(els);
}
}
_ => {}
}
}
}
fn check_item(&mut self, p: &PreambleItem<Span>) {
let Some(label) = identity_tag_label(&p.tag) else {
return;
};
let Some(kind) = volatile_macro_in_value(&p.value) else {
return;
};
let kind_label = kind.label();
self.diagnostics.push(Diagnostic::new(
&METADATA,
Severity::Warn,
format!(
"`{label}` contains {kind_label}; the expansion is evaluated at build time and \
breaks reproducibility — move the computation to a `%global` near the top of \
the spec and reference its result here"
),
p.data,
));
}
}
fn identity_tag_label(tag: &Tag) -> Option<String> {
match tag {
Tag::Name => Some("Name".into()),
Tag::Version => Some("Version".into()),
Tag::Release => Some("Release".into()),
Tag::Epoch => Some("Epoch".into()),
Tag::Summary => Some("Summary".into()),
Tag::License => Some("License".into()),
Tag::URL => Some("URL".into()),
Tag::Source(n) => Some(match n {
Some(n) => format!("Source{n}"),
None => "Source".into(),
}),
Tag::Patch(n) => Some(match n {
Some(n) => format!("Patch{n}"),
None => "Patch".into(),
}),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum VolatileKind {
Shell,
Lua,
}
impl VolatileKind {
fn label(self) -> &'static str {
match self {
Self::Shell => "a `%(...)` shell expansion",
Self::Lua => "a `%{lua:...}` block",
}
}
}
fn volatile_macro_in_value(v: &TagValue) -> Option<VolatileKind> {
match v {
TagValue::Text(t) => volatile_macro_in_text(t),
TagValue::ArchList(items) => items.iter().find_map(volatile_macro_in_text),
_ => None,
}
}
fn volatile_macro_in_text(t: &Text) -> Option<VolatileKind> {
t.segments.iter().find_map(|seg| {
let TextSegment::Macro(m) = seg else {
return None;
};
let direct = match m.kind {
MacroKind::Shell => Some(VolatileKind::Shell),
MacroKind::Lua => Some(VolatileKind::Lua),
_ => None,
};
direct
.or_else(|| m.args.iter().find_map(volatile_macro_in_text))
.or_else(|| m.with_value.as_ref().and_then(volatile_macro_in_text))
})
}
impl Lint for MacroShellExpansionInMetadata {
fn metadata(&self) -> &'static LintMetadata {
&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(src: &str) -> Vec<Diagnostic> {
let outcome = parse(src);
let mut lint = MacroShellExpansionInMetadata::new();
lint.visit_spec(&outcome.spec);
lint.take_diagnostics()
}
#[test]
fn flags_shell_in_version() {
let src = "Name: x\nVersion: %(date +%Y%m%d)\n";
let diags = run(src);
assert_eq!(diags.len(), 1, "{diags:?}");
assert_eq!(diags[0].lint_id, "RPM404");
assert!(diags[0].message.contains("Version"));
assert!(diags[0].message.contains("%(..."));
}
#[test]
fn flags_lua_in_release() {
let src = "Name: x\nRelease: %{lua:print(os.time())}\n";
let diags = run(src);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("Release"));
assert!(diags[0].message.contains("lua"));
}
#[test]
fn flags_shell_in_source0() {
let src = "Name: x\nSource0: https://example.com/%(date).tar.gz\n";
let diags = run(src);
assert_eq!(diags.len(), 1);
assert!(
diags[0].message.contains("Source0"),
"expected indexed label, got: {}",
diags[0].message
);
}
#[test]
fn flags_shell_in_url() {
let src = "Name: x\nURL: %(curl -s https://example.com/redirect)\n";
assert_eq!(run(src).len(), 1);
}
#[test]
fn silent_for_normal_metadata() {
let src = "Name: x\nVersion: 1.0\nRelease: 1%{?dist}\n";
assert!(run(src).is_empty());
}
#[test]
fn silent_for_shell_in_description() {
let src = "Name: x\nVersion: 1\n%description\nbuilt on %(date)\n";
assert!(run(src).is_empty());
}
#[test]
fn silent_for_normal_macro_in_release() {
let src = "Name: x\nRelease: 1%{?dist}\n";
assert!(run(src).is_empty());
}
#[test]
fn flags_in_subpackage_version() {
let src = "Name: x\nVersion: 1\n\
%package devel\n\
Version: %(date +%Y%m%d)\n\
%description devel\nbody\n";
assert_eq!(run(src).len(), 1);
}
#[test]
fn flags_inside_conditional_branch() {
let src = "Name: x\n%if 0%{?fedora}\nVersion: %(date +%Y)\n%endif\n";
assert_eq!(run(src).len(), 1);
}
#[test]
fn flags_multiple_distinct_tags() {
let src = "Name: x\nVersion: %(date +%Y)\nRelease: %{lua:print(1)}\n";
assert_eq!(run(src).len(), 2);
}
#[test]
fn flags_shell_in_patch0() {
let src = "Name: x\nVersion: 1\nPatch0: fix-%(date).patch\n";
let diags = run(src);
assert_eq!(diags.len(), 1, "{diags:?}");
assert!(
diags[0].message.contains("Patch0"),
"expected indexed label, got: {}",
diags[0].message
);
}
#[test]
fn flags_lua_in_summary() {
let src = "Name: x\nSummary: %{lua:print('hi')}\n";
let diags = run(src);
assert_eq!(diags.len(), 1, "{diags:?}");
assert!(diags[0].message.contains("Summary"));
assert!(diags[0].message.contains("lua"));
}
#[test]
fn flags_shell_in_license() {
let src = "Name: x\nLicense: %(echo MIT)\n";
let diags = run(src);
assert_eq!(diags.len(), 1, "{diags:?}");
assert!(diags[0].message.contains("License"));
}
#[test]
fn flags_shell_in_epoch() {
let src = "Name: x\nEpoch: %(echo 1)\n";
let diags = run(src);
assert_eq!(diags.len(), 1, "{diags:?}");
assert!(diags[0].message.contains("Epoch"));
}
#[test]
fn flags_shell_in_name() {
let src = "Name: %(echo foo)\nVersion: 1\n";
let diags = run(src);
assert_eq!(diags.len(), 1, "{diags:?}");
assert!(diags[0].message.contains("Name"));
}
#[test]
fn silent_for_shell_in_group() {
let src = "Name: x\nVersion: 1\nGroup: %(echo Applications/Text)\n";
assert!(run(src).is_empty());
}
#[test]
fn flags_shell_nested_in_with_value() {
let src = "Name: x\nVersion: %{?dist:%(date)}\n";
let diags = run(src);
assert_eq!(diags.len(), 1, "{diags:?}");
assert!(diags[0].message.contains("Version"));
assert!(diags[0].message.contains("%(..."));
}
}