Skip to main content

provenant/parsers/
rpm_yumdb.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use std::fs;
5use std::path::Path;
6
7use crate::parser_warn as warn;
8use packageurl::PackageUrl;
9
10use crate::models::{DatasourceId, PackageData, PackageType};
11use crate::parsers::utils::{CappedIterExt, read_file_to_string, truncate_field};
12
13use super::PackageParser;
14
15const PACKAGE_TYPE: PackageType = PackageType::Rpm;
16
17fn default_package_data() -> PackageData {
18    PackageData {
19        package_type: Some(PACKAGE_TYPE),
20        datasource_id: Some(DatasourceId::RpmYumdb),
21        ..Default::default()
22    }
23}
24
25fn parse_yumdb_dir_name(dir_name: &str) -> Option<(String, String, String)> {
26    let (_, package_part) = dir_name.split_once('-')?;
27
28    let (name_version_release, arch) = split_yumdb_arch(package_part)?;
29
30    let mut parts = name_version_release.rsplitn(3, '-');
31    let release = parts.next()?;
32    let version = parts.next()?;
33    let name = parts.next()?;
34
35    Some((
36        truncate_field(name.to_string()),
37        truncate_field(format!("{}-{}", version, release)),
38        truncate_field(arch.to_string()),
39    ))
40}
41
42fn split_yumdb_arch(package_part: &str) -> Option<(&str, &str)> {
43    if let Some((name_version_release, arch)) = package_part.rsplit_once('-')
44        && is_plausible_rpm_arch(arch)
45    {
46        return Some((name_version_release, arch));
47    }
48
49    package_part.rsplit_once('.')
50}
51
52fn is_plausible_rpm_arch(arch: &str) -> bool {
53    arch.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
54        && arch.chars().any(|c| c.is_ascii_alphabetic())
55}
56
57fn build_yumdb_purl(name: &str, version: &str, arch: &str) -> Option<String> {
58    let mut purl = PackageUrl::new(PACKAGE_TYPE.as_str(), name).ok()?;
59    purl.with_version(version).ok()?;
60    purl.add_qualifier("arch", arch).ok()?;
61    Some(truncate_field(purl.to_string()))
62}
63
64pub struct RpmYumdbParser;
65
66impl PackageParser for RpmYumdbParser {
67    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
68
69    fn is_match(path: &Path) -> bool {
70        path.file_name().and_then(|name| name.to_str()) == Some("from_repo")
71            && path.to_string_lossy().contains("/var/lib/yum/yumdb/")
72    }
73
74    fn extract_packages(path: &Path) -> Vec<PackageData> {
75        let Some(package_dir) = path.parent() else {
76            return vec![default_package_data()];
77        };
78
79        let Some(dir_name) = package_dir.file_name().and_then(|name| name.to_str()) else {
80            return vec![default_package_data()];
81        };
82
83        let Some((name, version, arch)) = parse_yumdb_dir_name(dir_name) else {
84            warn!(
85                "Failed to parse yumdb package directory name {:?}",
86                package_dir
87            );
88            return vec![default_package_data()];
89        };
90
91        let mut extra_data = std::collections::HashMap::new();
92        let entries = match fs::read_dir(package_dir) {
93            Ok(entries) => entries,
94            Err(e) => {
95                warn!(
96                    "Failed to read yumdb package directory {:?}: {}",
97                    package_dir, e
98                );
99                return vec![default_package_data()];
100            }
101        };
102
103        let entries: Vec<_> = entries
104            .flatten()
105            .capped("rpm yumdb package directory entries")
106            .collect();
107
108        for entry in entries {
109            let key_path = entry.path();
110            if !key_path.is_file() {
111                continue;
112            }
113
114            let Some(key) = key_path.file_name().and_then(|name| name.to_str()) else {
115                continue;
116            };
117
118            match read_file_to_string(&key_path, None) {
119                Ok(value) => {
120                    let value = value.trim();
121                    if !value.is_empty() {
122                        extra_data.insert(
123                            key.to_string(),
124                            serde_json::Value::String(truncate_field(value.to_string())),
125                        );
126                    }
127                }
128                Err(e) => warn!("Failed to read yumdb key {:?}: {}", key_path, e),
129            }
130        }
131
132        let qualifiers = std::iter::once(("arch".to_string(), arch.clone())).collect();
133
134        vec![PackageData {
135            datasource_id: Some(DatasourceId::RpmYumdb),
136            package_type: Some(PACKAGE_TYPE),
137            name: Some(name.clone()),
138            version: Some(version.clone()),
139            qualifiers: Some(qualifiers),
140            purl: build_yumdb_purl(&name, &version, &arch),
141            extra_data: (!extra_data.is_empty()).then_some(extra_data),
142            is_virtual: true,
143            ..Default::default()
144        }]
145    }
146
147    fn metadata() -> Vec<super::metadata::ParserMetadata> {
148        vec![super::metadata::ParserMetadata {
149            description: "RPM yumdb metadata",
150            file_patterns: &["**/var/lib/yum/yumdb/*/*/from_repo"],
151            package_type: "rpm",
152            primary_language: "",
153            documentation_url: Some("http://yum.baseurl.org/wiki/YumDB.html"),
154        }]
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use std::fs;
162    use tempfile::tempdir;
163
164    #[test]
165    fn test_parse_yumdb_dir_name() {
166        let parsed = parse_yumdb_dir_name("p/bash-5.0-1.el8.x86_64");
167        assert!(parsed.is_none());
168
169        let parsed = parse_yumdb_dir_name("abc123-bash-5.0-1.el8.x86_64").unwrap();
170        assert_eq!(parsed.0, "bash");
171        assert_eq!(parsed.1, "5.0-1.el8");
172        assert_eq!(parsed.2, "x86_64");
173
174        let parsed = parse_yumdb_dir_name(
175            "f5d239a7a36834cca0361050b89f9dcf6675df03-bash-4.2.46-34.amzn2-x86_64",
176        )
177        .unwrap();
178        assert_eq!(parsed.0, "bash");
179        assert_eq!(parsed.1, "4.2.46-34.amzn2");
180        assert_eq!(parsed.2, "x86_64");
181    }
182
183    #[test]
184    fn test_is_match() {
185        assert!(RpmYumdbParser::is_match(Path::new(
186            "/rootfs/var/lib/yum/yumdb/p/abc123-bash-5.0-1.el8.x86_64/from_repo"
187        )));
188        assert!(!RpmYumdbParser::is_match(Path::new(
189            "/rootfs/var/lib/yum/yumdb/p/abc123-bash-5.0-1.el8.x86_64/reason"
190        )));
191    }
192
193    #[test]
194    fn test_extract_packages_reads_sibling_metadata() {
195        let tempdir = tempdir().unwrap();
196        let package_dir = tempdir
197            .path()
198            .join("rootfs/var/lib/yum/yumdb/p/abc123-bash-5.0-1.el8.x86_64");
199        fs::create_dir_all(&package_dir).unwrap();
200        fs::write(package_dir.join("from_repo"), "baseos\n").unwrap();
201        fs::write(package_dir.join("reason"), "dep\n").unwrap();
202        fs::write(package_dir.join("releasever"), "8\n").unwrap();
203
204        let packages = RpmYumdbParser::extract_packages(&package_dir.join("from_repo"));
205        let pkg = &packages[0];
206
207        assert_eq!(pkg.datasource_id, Some(DatasourceId::RpmYumdb));
208        assert_eq!(pkg.name.as_deref(), Some("bash"));
209        assert_eq!(pkg.version.as_deref(), Some("5.0-1.el8"));
210        assert_eq!(
211            pkg.qualifiers.as_ref().and_then(|q| q.get("arch")),
212            Some(&"x86_64".to_string())
213        );
214        let extra = pkg.extra_data.as_ref().unwrap();
215        assert_eq!(extra["from_repo"], "baseos");
216        assert_eq!(extra["reason"], "dep");
217        assert_eq!(extra["releasever"], "8");
218    }
219}