Skip to main content

rpm_qa/
parse.rs

1use anyhow::{Context, Result, bail};
2use camino::{Utf8Path, Utf8PathBuf};
3use std::io::{BufRead, Read};
4
5use crate::*;
6
7/// The `--queryformat` string used to query RPM. This is the format that
8/// `load_from_str` and `load_from_reader` expect.
9///
10/// The `\t` and `\n` here are literal backslash escapes for rpm to interpret
11/// (raw strings pass them through without Rust processing them).
12pub(crate) const QUERYFORMAT: &str = concat!(
13    // Per-package header line:
14    r"@@PKG@@\t%{NAME}\t%{VERSION}\t%{RELEASE}\t%{EPOCH}\t%{ARCH}",
15    r"\t%{LICENSE}\t%{SIZE}\t%{BUILDTIME}\t%{INSTALLTIME}",
16    r"\t%{SOURCERPM}\t%{FILEDIGESTALGO}\n",
17    // Per-file lines (iterated with []):
18    r"[@@FILE@@\t%{FILENAMES}\t%{FILESIZES}\t%{FILEMODES}\t%{FILEMTIMES}",
19    r"\t%{FILEDIGESTS}\t%{FILEFLAGS}",
20    r"\t%{FILEUSERNAME}\t%{FILEGROUPNAME}\t%{FILELINKTOS}\n]",
21    // Per-changelog lines (iterated with []):
22    r"[@@CL@@\t%{CHANGELOGTIME}\n]",
23);
24
25/// Expected number of tab-separated fields after stripping the @@PKG@@ prefix.
26const PKG_FIELDS: usize = 11;
27/// Expected number of tab-separated fields after stripping the @@FILE@@ prefix.
28const FILE_FIELDS: usize = 9;
29
30/// Stream-parse queryformat output from a reader.
31pub(crate) fn load_from_reader_impl<R: Read>(reader: R) -> Result<Packages> {
32    let mut packages = Packages::new();
33    let mut current_pkg: Option<Package> = None;
34    // Whether the current package is gpg-pubkey (skip its FILE/CL lines).
35    let mut skip = false;
36
37    for (line_no, line) in std::io::BufReader::new(reader).lines().enumerate() {
38        let line = line.context("reading line")?;
39        if line.is_empty() {
40            continue;
41        }
42
43        if let Some(rest) = line.strip_prefix("@@PKG@@\t") {
44            // Finalize previous package.
45            if let Some(pkg) = current_pkg.take() {
46                packages.insert(pkg.name.clone(), pkg);
47            }
48
49            let fields: Vec<&str> = rest.split('\t').collect();
50            if fields.len() != PKG_FIELDS {
51                bail!(
52                    "line {}: expected {PKG_FIELDS} fields in PKG line, got {}",
53                    line_no + 1,
54                    fields.len()
55                );
56            }
57
58            let name = fields[0];
59            // Skip gpg-pubkey entries (they lack Arch and aren't real packages).
60            if name == "gpg-pubkey" {
61                skip = true;
62                continue;
63            }
64
65            skip = false;
66            let pkg = parse_pkg_header(&fields)
67                .with_context(|| format!("parsing package header at line {}", line_no + 1))?;
68            current_pkg = Some(pkg);
69        } else if skip {
70            // Consume FILE/CL lines for skipped packages.
71            continue;
72        } else if let Some(rest) = line.strip_prefix("@@FILE@@\t") {
73            let pkg = current_pkg
74                .as_mut()
75                .ok_or_else(|| anyhow::anyhow!("line {}: FILE line before any PKG", line_no + 1))?;
76            let fields: Vec<&str> = rest.split('\t').collect();
77            if fields.len() != FILE_FIELDS {
78                bail!(
79                    "line {}: expected {} fields in FILE line for '{}', got {}",
80                    line_no + 1,
81                    FILE_FIELDS,
82                    pkg.name,
83                    fields.len()
84                );
85            }
86            let (path, info) = parse_file_line(&fields)
87                .with_context(|| format!("line {}: file in '{}'", line_no + 1, pkg.name))?;
88            pkg.files.insert(path, info);
89        } else if let Some(rest) = line.strip_prefix("@@CL@@\t") {
90            let pkg = current_pkg
91                .as_mut()
92                .ok_or_else(|| anyhow::anyhow!("line {}: CL line before any PKG", line_no + 1))?;
93            let time: u64 = rest.parse().with_context(|| {
94                format!(
95                    "line {}: invalid changelog time for '{}'",
96                    line_no + 1,
97                    pkg.name
98                )
99            })?;
100            pkg.changelog_times.push(time);
101        } else {
102            bail!(
103                "line {}: unexpected line format: {}",
104                line_no + 1,
105                &line[..line.len().min(80)]
106            );
107        }
108    }
109
110    // Finalize last package.
111    if let Some(pkg) = current_pkg.take() {
112        packages.insert(pkg.name.clone(), pkg);
113    }
114
115    Ok(packages)
116}
117
118/// Parse queryformat output from a string.
119pub(crate) fn load_from_str_impl(input: &str) -> Result<Packages> {
120    load_from_reader_impl(input.as_bytes())
121}
122
123/// Parse the package header fields from a @@PKG@@ line into a partially-built
124/// Package (files and changelog_times are filled in later).
125fn parse_pkg_header(fields: &[&str]) -> Result<Package> {
126    assert_eq!(fields.len(), PKG_FIELDS); // checked by caller
127    let name = fields[0];
128    let epoch = match parse_optional(fields[3]) {
129        None => None,
130        Some(s) => Some(
131            s.parse::<u32>()
132                .with_context(|| format!("{name}: invalid epoch '{s}'"))?,
133        ),
134    };
135    let arch = parse_optional(fields[4])
136        .ok_or_else(|| anyhow::anyhow!("{name}: missing arch"))?
137        .to_string();
138    let size = fields[6]
139        .parse::<u64>()
140        .with_context(|| format!("{name}: invalid size"))?;
141    let buildtime = fields[7]
142        .parse::<u64>()
143        .with_context(|| format!("{name}: invalid buildtime"))?;
144    let installtime = fields[8]
145        .parse::<u64>()
146        .with_context(|| format!("{name}: invalid installtime"))?;
147    let sourcerpm = parse_optional(fields[9]).map(|s| s.to_string());
148
149    let digest_algo = match parse_optional(fields[10]) {
150        None => None,
151        Some(s) => {
152            let v = s
153                .parse::<u32>()
154                .with_context(|| format!("{name}: invalid filedigestalgo '{s}'"))?;
155            Some(
156                DigestAlgorithm::try_from(v)
157                    .map_err(|_| anyhow::anyhow!("{name}: unknown digest algorithm {v}"))?,
158            )
159        }
160    };
161
162    Ok(Package {
163        name: name.to_string(),
164        version: fields[1].to_string(),
165        release: fields[2].to_string(),
166        epoch,
167        arch,
168        license: fields[5].to_string(),
169        size,
170        buildtime,
171        installtime,
172        sourcerpm,
173        digest_algo,
174        changelog_times: Vec::new(),
175        files: Files::new(),
176    })
177}
178
179/// Map the RPM `(none)` sentinel to `None`.
180fn parse_optional(s: &str) -> Option<&str> {
181    if s == "(none)" { None } else { Some(s) }
182}
183
184impl TryFrom<u32> for DigestAlgorithm {
185    type Error = ();
186
187    fn try_from(v: u32) -> Result<Self, Self::Error> {
188        match v {
189            x if x == Self::Md5 as u32 => Ok(Self::Md5),
190            x if x == Self::Sha1 as u32 => Ok(Self::Sha1),
191            x if x == Self::RipeMd160 as u32 => Ok(Self::RipeMd160),
192            x if x == Self::Md2 as u32 => Ok(Self::Md2),
193            x if x == Self::Tiger192 as u32 => Ok(Self::Tiger192),
194            x if x == Self::Haval5160 as u32 => Ok(Self::Haval5160),
195            x if x == Self::Sha256 as u32 => Ok(Self::Sha256),
196            x if x == Self::Sha384 as u32 => Ok(Self::Sha384),
197            x if x == Self::Sha512 as u32 => Ok(Self::Sha512),
198            x if x == Self::Sha224 as u32 => Ok(Self::Sha224),
199            x if x == Self::Sha3_256 as u32 => Ok(Self::Sha3_256),
200            x if x == Self::Sha3_512 as u32 => Ok(Self::Sha3_512),
201            _ => Err(()),
202        }
203    }
204}
205
206/// Parse a @@FILE@@ line and return the path and file info.
207fn parse_file_line(fields: &[&str]) -> Result<(Utf8PathBuf, FileInfo)> {
208    assert_eq!(fields.len(), FILE_FIELDS); // checked by caller
209    let path = Utf8Path::new(fields[0]);
210    let size = fields[1]
211        .parse::<u64>()
212        .with_context(|| format!("invalid filesize for {path}"))?;
213    let mode = fields[2]
214        .parse::<u16>()
215        .with_context(|| format!("invalid filemode for {path}"))?;
216    let mtime = fields[3]
217        .parse::<u64>()
218        .with_context(|| format!("invalid filemtime for {path}"))?;
219    let digest = if fields[4].is_empty() {
220        None
221    } else {
222        Some(fields[4].to_string())
223    };
224    let flags = fields[5]
225        .parse::<u32>()
226        .with_context(|| format!("invalid fileflags for {path}"))?;
227    let linkto = if fields[8].is_empty() {
228        None
229    } else {
230        Some(Utf8PathBuf::from(fields[8]))
231    };
232
233    let info = FileInfo {
234        size,
235        mode,
236        mtime,
237        digest,
238        flags: FileFlags::from_raw(flags),
239        user: fields[6].to_string(),
240        group: fields[7].to_string(),
241        linkto,
242    };
243
244    Ok((path.to_path_buf(), info))
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    fn make_pkg_line(name: &str) -> String {
252        format!(
253            "@@PKG@@\t{name}\t1.0\t1.fc42\t(none)\tx86_64\tMIT\t100\t1000\t2000\tfoo.src.rpm\t8\n"
254        )
255    }
256
257    fn make_file_line(path: &str) -> String {
258        format!("@@FILE@@\t{path}\t100\t33188\t1000\taabbccdd\t0\troot\troot\t\n")
259    }
260
261    #[test]
262    fn test_empty_input() {
263        let packages = load_from_str_impl("").unwrap();
264        assert!(packages.is_empty());
265    }
266
267    #[test]
268    fn test_gpg_pubkey_skipped() {
269        let input =
270            "@@PKG@@\tgpg-pubkey\t1.0\t1.fc42\t(none)\t(none)\tpubkey\t0\t0\t0\t(none)\t(none)\n";
271        let packages = load_from_str_impl(input).unwrap();
272        assert!(packages.is_empty());
273    }
274
275    #[test]
276    fn test_none_optional_fields() {
277        let input = "@@PKG@@\ttest\t1.0\t1\t(none)\tx86_64\tMIT\t0\t0\t0\t(none)\t(none)\n";
278        let packages = load_from_str_impl(input).unwrap();
279        assert_eq!(packages["test"].epoch, None);
280        assert_eq!(packages["test"].sourcerpm, None);
281        assert_eq!(packages["test"].digest_algo, None);
282        assert!(packages["test"].files.is_empty());
283        assert!(packages["test"].changelog_times.is_empty());
284    }
285
286    #[test]
287    fn test_no_files_with_changelog() {
288        let mut input = make_pkg_line("test");
289        input.push_str("@@CL@@\t3000\n");
290        input.push_str("@@CL@@\t2000\n");
291        input.push_str("@@CL@@\t1000\n");
292        let packages = load_from_str_impl(&input).unwrap();
293        assert!(packages["test"].files.is_empty());
294        assert_eq!(packages["test"].changelog_times, vec![3000, 2000, 1000]);
295    }
296
297    #[test]
298    fn test_with_files_no_changelog() {
299        let mut input = make_pkg_line("test");
300        input.push_str(&make_file_line("/usr/bin/foo"));
301        input.push_str(&make_file_line("/usr/bin/bar"));
302        let packages = load_from_str_impl(&input).unwrap();
303        assert_eq!(packages["test"].files.len(), 2);
304        assert!(packages["test"].changelog_times.is_empty());
305    }
306
307    #[test]
308    fn test_with_files_and_changelog() {
309        let mut input = make_pkg_line("test");
310        input.push_str(&make_file_line("/usr/bin/foo"));
311        input.push_str(&make_file_line("/usr/bin/bar"));
312        input.push_str("@@CL@@\t2000\n");
313        input.push_str("@@CL@@\t1000\n");
314        let packages = load_from_str_impl(&input).unwrap();
315        assert_eq!(packages["test"].files.len(), 2);
316        assert_eq!(packages["test"].changelog_times, vec![2000, 1000]);
317    }
318
319    #[test]
320    fn test_multiple_packages() {
321        let mut input = make_pkg_line("alpha");
322        input.push_str(&make_file_line("/usr/bin/alpha"));
323        input.push_str(&make_pkg_line("beta"));
324        input.push_str(&make_file_line("/usr/bin/beta"));
325        let packages = load_from_str_impl(&input).unwrap();
326        assert_eq!(packages.len(), 2);
327        assert!(packages.contains_key("alpha"));
328        assert!(packages.contains_key("beta"));
329    }
330
331    #[test]
332    fn test_error_conditions() {
333        // Wrong number of fields in PKG line.
334        assert!(load_from_str_impl("@@PKG@@\tfoo\t1.0\n").is_err());
335
336        // Wrong number of fields in FILE line.
337        let mut input = make_pkg_line("test");
338        input.push_str("@@FILE@@\t/a\t0\n");
339        assert!(load_from_str_impl(&input).is_err());
340
341        // FILE line before any PKG line.
342        assert!(load_from_str_impl("@@FILE@@\t/a\t0\t33188\t0\t\t0\troot\troot\t\n").is_err());
343
344        // Unrecognized line format.
345        assert!(load_from_str_impl("garbage\n").is_err());
346    }
347
348    #[test]
349    fn test_symlink_and_empty_digest() {
350        let mut input = make_pkg_line("test");
351        // A symlink with empty digest
352        input.push_str("@@FILE@@\t/usr/bin/sh\t4\t41471\t1000\t\t0\troot\troot\tbash\n");
353        let packages = load_from_str_impl(&input).unwrap();
354        let sh = &packages["test"].files[Utf8Path::new("/usr/bin/sh")];
355        assert!(sh.digest.is_none());
356        assert_eq!(sh.linkto.as_deref(), Some(Utf8Path::new("bash")));
357    }
358}