use rpm_spec::ast::{
AttrField, AttrFields, ConfigFlag, FileDirective, FileEntry, Span, Text, TextSegment,
};
use rpm_spec_profile::Profile;
const EXPAND_DEPTH: u8 = 8;
#[derive(Debug)]
pub struct FilesClassifier<'a> {
profile: &'a Profile,
dir_table: Vec<(String, &'static str)>,
}
impl<'a> FilesClassifier<'a> {
pub fn new(profile: &'a Profile) -> Self {
let dir_table = build_dir_table(profile);
Self { profile, dir_table }
}
pub fn classify<'e>(&self, entry: &'e FileEntry<Span>) -> EntryClassification<'e> {
let resolved_path = entry.path.as_ref().and_then(|p| self.expand_path(&p.path));
let directives = summarize_directives(&entry.directives);
let kind_hints = match resolved_path.as_deref() {
Some(path) => self.detect_kinds(path),
None => KindHints::default(),
};
EntryClassification {
entry,
resolved_path,
directives,
kind_hints,
}
}
pub fn expand_path(&self, text: &Text) -> Option<String> {
let mut out = String::new();
for seg in &text.segments {
match seg {
TextSegment::Literal(s) => out.push_str(s),
TextSegment::Macro(m) => {
use rpm_spec::ast::{ConditionalMacro, MacroKind};
if !matches!(m.kind, MacroKind::Plain | MacroKind::Braced) {
return None;
}
if !matches!(m.conditional, ConditionalMacro::None) {
return None;
}
if !m.args.is_empty() || m.with_value.is_some() {
return None;
}
let expanded = self
.profile
.macros
.expand_to_literal(&m.name, EXPAND_DEPTH)?;
out.push_str(&expanded);
}
_ => return None,
}
}
Some(out)
}
fn detect_kinds(&self, path: &str) -> KindHints {
let mut hints = KindHints::default();
let trimmed = path.trim();
if trimmed.starts_with("/etc/") || trimmed == "/etc" {
hints.under_etc = true;
}
if trimmed.starts_with("/usr/") || trimmed == "/usr" {
hints.under_usr = true;
}
if trimmed.starts_with("/var/run/") || trimmed.starts_with("/run/") {
hints.under_var_run = true;
}
if trimmed.starts_with("/var/lock/") {
hints.under_var_lock = true;
}
if trimmed.starts_with("/usr/lib/debug")
|| trimmed.contains("/.build-id/")
|| trimmed.ends_with(".debug")
{
hints.under_debug = true;
}
if trimmed.starts_with("/usr/share/locale/") || trimmed.starts_with("/usr/lib/locale/") {
hints.under_locale_dir = true;
}
if trimmed.ends_with(".mo") && hints.under_locale_dir {
hints.is_locale_mo = true;
}
if trimmed.ends_with(".h") || trimmed.ends_with(".hpp") || trimmed.ends_with(".hxx") {
hints.is_devel_header = true;
}
if trimmed.ends_with(".pc") && trimmed.contains("/pkgconfig/") {
hints.is_pkgconfig = true;
}
if trimmed.ends_with("Config.cmake")
|| trimmed.ends_with("-config.cmake")
|| trimmed.ends_with("ConfigVersion.cmake")
{
hints.is_cmake_config = true;
}
if let Some(stem) = strip_so_extension(trimmed) {
hints.is_unversioned_so = !stem.contains(".so.");
}
if let Some(ext) = systemd_unit_ext(trimmed) {
hints.systemd_unit_ext = Some(ext);
}
if (trimmed.starts_with("/usr/lib/tmpfiles.d/")
|| trimmed.starts_with("/etc/tmpfiles.d/")
|| trimmed.contains("/tmpfiles.d/"))
&& trimmed.ends_with(".conf")
{
hints.is_tmpfiles_conf = true;
}
if (trimmed.starts_with("/usr/lib/sysusers.d/") || trimmed.contains("/sysusers.d/"))
&& trimmed.ends_with(".conf")
{
hints.is_sysusers_conf = true;
}
if let Some((_prefix, label)) = self
.dir_table
.iter()
.find(|(prefix, _)| trimmed == prefix.trim_end_matches('/'))
{
hints.standard_dir_macro = Some(*label);
}
if let Some(rest) = trimmed.strip_suffix("/*")
&& let Some((_prefix, label)) = self
.dir_table
.iter()
.find(|(prefix, _)| rest == prefix.trim_end_matches('/'))
{
hints.broad_glob_for = Some(*label);
}
hints
}
}
#[derive(Debug)]
pub struct EntryClassification<'a> {
pub entry: &'a FileEntry<Span>,
pub resolved_path: Option<String>,
pub directives: DirectiveSummary,
pub kind_hints: KindHints,
}
impl EntryClassification<'_> {
pub fn span(&self) -> Span {
self.entry.data
}
}
#[derive(Debug, Default, Clone)]
pub struct DirectiveSummary {
pub config: Option<ConfigKind>,
pub is_doc: bool,
pub is_license: bool,
pub is_ghost: bool,
pub is_dir: bool,
pub is_artifact: bool,
pub is_missing_ok: bool,
pub has_lang: bool,
pub attr: Option<AttrSummary>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigKind {
Plain,
NoReplace,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct AttrSummary {
pub mode: Option<u32>,
pub user_literal: bool,
pub group_literal: bool,
}
#[derive(Debug, Default, Clone)]
pub struct KindHints {
pub under_etc: bool,
pub under_usr: bool,
pub under_var_run: bool,
pub under_var_lock: bool,
pub under_debug: bool,
pub under_locale_dir: bool,
pub is_locale_mo: bool,
pub is_devel_header: bool,
pub is_pkgconfig: bool,
pub is_cmake_config: bool,
pub is_unversioned_so: bool,
pub systemd_unit_ext: Option<&'static str>,
pub is_tmpfiles_conf: bool,
pub is_sysusers_conf: bool,
pub standard_dir_macro: Option<&'static str>,
pub broad_glob_for: Option<&'static str>,
}
fn build_dir_table(profile: &Profile) -> Vec<(String, &'static str)> {
const KNOWN: &[&str] = &[
"_bindir",
"_sbindir",
"_libdir",
"_libexecdir",
"_includedir",
"_datadir",
"_mandir",
"_infodir",
"_localstatedir",
"_sharedstatedir",
"_sysconfdir",
"_unitdir",
"_userunitdir",
"_tmpfilesdir",
"_sysusersdir",
"_prefix",
"_exec_prefix",
"_docdir",
"_defaultlicensedir",
];
let mut out: Vec<(String, &'static str)> = KNOWN
.iter()
.filter_map(|name| {
profile
.macros
.expand_to_literal(name, EXPAND_DEPTH)
.map(|literal| (literal, *name))
})
.collect();
out.sort_by_key(|entry| std::cmp::Reverse(entry.0.len()));
out
}
fn summarize_directives(dirs: &[FileDirective]) -> DirectiveSummary {
let mut summary = DirectiveSummary::default();
for d in dirs {
match d {
FileDirective::Config(flags) => {
summary.config = Some(
if flags.iter().any(|f| matches!(f, ConfigFlag::NoReplace)) {
ConfigKind::NoReplace
} else {
ConfigKind::Plain
},
);
}
FileDirective::Doc => summary.is_doc = true,
FileDirective::License => summary.is_license = true,
FileDirective::Ghost => summary.is_ghost = true,
FileDirective::Dir => summary.is_dir = true,
FileDirective::Artifact => summary.is_artifact = true,
FileDirective::MissingOk => summary.is_missing_ok = true,
FileDirective::Lang(_) => summary.has_lang = true,
FileDirective::Attr(a) => summary.attr = Some(attr_summary(a)),
_ => {}
}
}
summary
}
fn attr_summary(a: &AttrFields) -> AttrSummary {
AttrSummary {
mode: match &a.mode {
AttrField::Numeric(n) => Some(*n),
_ => None,
},
user_literal: matches!(a.user, AttrField::Name(_)),
group_literal: matches!(a.group, AttrField::Name(_)),
}
}
fn strip_so_extension(path: &str) -> Option<&str> {
let last = path.rsplit('/').next()?;
if !last.contains(".so") {
return None;
}
Some(last)
}
fn systemd_unit_ext(path: &str) -> Option<&'static str> {
const EXTS: &[&str] = &[
".service",
".socket",
".timer",
".path",
".mount",
".target",
".automount",
".slice",
];
EXTS.iter().copied().find(|ext| path.ends_with(ext))
}
#[cfg(test)]
mod tests {
use super::*;
use rpm_spec::ast::{FilePath, Text};
use rpm_spec_profile::{MacroEntry, Profile, Provenance};
fn make_profile(macros: &[(&str, &str)]) -> Profile {
let mut p = Profile::default();
for (name, body) in macros {
p.macros
.insert(*name, MacroEntry::literal(*body, Provenance::Override));
}
p
}
fn entry_with_path(path: &str) -> FileEntry<Span> {
FileEntry {
directives: Vec::new(),
path: Some(FilePath {
path: Text::from(path),
}),
data: Span::default(),
}
}
fn fedora_like() -> Profile {
make_profile(&[
("_prefix", "/usr"),
("_bindir", "/usr/bin"),
("_sbindir", "/usr/sbin"),
("_libdir", "/usr/lib64"),
("_datadir", "/usr/share"),
("_includedir", "/usr/include"),
("_sysconfdir", "/etc"),
("_unitdir", "/usr/lib/systemd/system"),
("_tmpfilesdir", "/usr/lib/tmpfiles.d"),
])
}
#[test]
fn resolves_literal_path_unchanged() {
let p = fedora_like();
let c = FilesClassifier::new(&p);
let e = entry_with_path("/etc/foo.conf");
let cls = c.classify(&e);
assert_eq!(cls.resolved_path.as_deref(), Some("/etc/foo.conf"));
assert!(cls.kind_hints.under_etc);
}
#[test]
fn expands_braced_macro_in_path() {
let p = fedora_like();
let c = FilesClassifier::new(&p);
let mut e = entry_with_path("");
e.path = Some(FilePath {
path: text_macro_and_literal("_bindir", "/foo"),
});
let cls = c.classify(&e);
assert_eq!(cls.resolved_path.as_deref(), Some("/usr/bin/foo"));
assert!(cls.kind_hints.under_usr);
}
#[test]
fn unresolvable_macro_leaves_path_none() {
let p = fedora_like();
let c = FilesClassifier::new(&p);
let mut e = entry_with_path("");
e.path = Some(FilePath {
path: text_macro_and_literal("totally_undefined", "/x"),
});
let cls = c.classify(&e);
assert!(cls.resolved_path.is_none());
assert!(!cls.kind_hints.under_usr);
}
#[test]
fn standard_dir_macro_detected_for_bare_bindir() {
let p = fedora_like();
let c = FilesClassifier::new(&p);
let mut e = entry_with_path("");
e.path = Some(FilePath {
path: text_macro("_bindir"),
});
let cls = c.classify(&e);
assert_eq!(cls.kind_hints.standard_dir_macro, Some("_bindir"));
}
#[test]
fn broad_glob_detected_for_datadir_star() {
let p = fedora_like();
let c = FilesClassifier::new(&p);
let mut e = entry_with_path("");
e.path = Some(FilePath {
path: text_macro_and_literal("_datadir", "/*"),
});
let cls = c.classify(&e);
assert_eq!(cls.kind_hints.broad_glob_for, Some("_datadir"));
}
#[test]
fn locale_mo_detected() {
let p = fedora_like();
let c = FilesClassifier::new(&p);
let e = entry_with_path("/usr/share/locale/ru/LC_MESSAGES/foo.mo");
let cls = c.classify(&e);
assert!(cls.kind_hints.is_locale_mo);
assert!(cls.kind_hints.under_locale_dir);
}
#[test]
fn devel_header_detected() {
let p = fedora_like();
let c = FilesClassifier::new(&p);
let e = entry_with_path("/usr/include/foo.h");
let cls = c.classify(&e);
assert!(cls.kind_hints.is_devel_header);
}
#[test]
fn pkgconfig_detected() {
let p = fedora_like();
let c = FilesClassifier::new(&p);
let e = entry_with_path("/usr/lib64/pkgconfig/foo.pc");
let cls = c.classify(&e);
assert!(cls.kind_hints.is_pkgconfig);
}
#[test]
fn systemd_unit_ext_detected() {
let p = fedora_like();
let c = FilesClassifier::new(&p);
let e = entry_with_path("/usr/lib/systemd/system/foo.service");
let cls = c.classify(&e);
assert_eq!(cls.kind_hints.systemd_unit_ext, Some(".service"));
}
#[test]
fn debug_path_detected() {
let p = fedora_like();
let c = FilesClassifier::new(&p);
let e = entry_with_path("/usr/lib/debug/usr/bin/foo.debug");
let cls = c.classify(&e);
assert!(cls.kind_hints.under_debug);
}
#[test]
fn unversioned_so_detected() {
let p = fedora_like();
let c = FilesClassifier::new(&p);
let e = entry_with_path("/usr/lib64/libfoo.so");
let cls = c.classify(&e);
assert!(cls.kind_hints.is_unversioned_so);
}
#[test]
fn versioned_so_not_flagged_as_unversioned() {
let p = fedora_like();
let c = FilesClassifier::new(&p);
let e = entry_with_path("/usr/lib64/libfoo.so.1");
let cls = c.classify(&e);
assert!(!cls.kind_hints.is_unversioned_so);
}
#[test]
fn directive_summary_collects_config_doc_license() {
let p = fedora_like();
let c = FilesClassifier::new(&p);
let mut e = entry_with_path("/etc/foo.conf");
e.directives = vec![FileDirective::Config(vec![ConfigFlag::NoReplace])];
let cls = c.classify(&e);
assert_eq!(cls.directives.config, Some(ConfigKind::NoReplace));
let mut e2 = entry_with_path("/usr/share/doc/foo/LICENSE");
e2.directives = vec![FileDirective::License];
let cls2 = c.classify(&e2);
assert!(cls2.directives.is_license);
}
fn text_macro(name: &str) -> Text {
use rpm_spec::ast::{ConditionalMacro, MacroKind, MacroRef};
Text {
segments: vec![TextSegment::macro_ref(MacroRef {
kind: MacroKind::Braced,
name: name.into(),
args: Vec::new(),
conditional: ConditionalMacro::None,
with_value: None,
})],
}
}
fn text_macro_and_literal(name: &str, suffix: &str) -> Text {
use rpm_spec::ast::{ConditionalMacro, MacroKind, MacroRef};
Text {
segments: vec![
TextSegment::macro_ref(MacroRef {
kind: MacroKind::Braced,
name: name.into(),
args: Vec::new(),
conditional: ConditionalMacro::None,
with_value: None,
}),
TextSegment::Literal(suffix.into()),
],
}
}
}