apt_parse/
policy.rs

1// SPDX-FileCopyrightText: Peter Pentchev <roam@ringlet.net>
2// SPDX-License-Identifier: BSD-2-Clause
3//! Parse the output of `apt-cache policy`.
4
5use std::collections::HashMap;
6
7use anyhow::anyhow;
8use nom::{
9    branch::alt,
10    bytes::complete::tag,
11    character::complete::{alphanumeric0, char as p_char, i32 as p_i32, none_of, one_of, space1},
12    combinator::{all_consuming, opt},
13    error::Error as NError,
14    multi::{many0, many1, separated_list1},
15    sequence::{delimited, preceded, terminated, tuple},
16    Err as NErr, IResult,
17};
18
19use crate::defs::Error;
20
21/// The location of a repository's files.
22#[derive(Debug, PartialEq, Eq)]
23#[non_exhaustive]
24pub enum RepoLoc {
25    /// The path to a local file.
26    LocalFile(String),
27
28    /// A URL pointing to a remote repository.
29    Url {
30        /// The URL itself.
31        url: String,
32
33        /// The release suite.
34        suite: String,
35
36        /// The release component.
37        component: String,
38
39        /// The architecture of the packages.
40        arch: Option<String>,
41    },
42}
43
44/// The attributes of a repository.
45#[derive(Debug)]
46enum RepoAttr {
47    /// The origin URL of the repository.
48    Origin(String),
49
50    /// The Debian release that this repository represents.
51    Release(HashMap<char, String>),
52}
53
54/// A repository with its priority.
55#[derive(Debug)]
56pub struct Repo {
57    /// The priority which Apt will use to determine whether to use these packages.
58    prio: i32,
59
60    /// The repository location.
61    loc: RepoLoc,
62
63    /// The origin URL of the repository, if specified.
64    origin: Option<String>,
65
66    /// The Debian release that this repository represents.
67    release: HashMap<char, String>,
68}
69
70impl Repo {
71    /// The priority which Apt will use to determine whether to use these packages.
72    #[inline]
73    #[must_use]
74    pub const fn prio(&self) -> i32 {
75        self.prio
76    }
77
78    /// The repository location.
79    #[inline]
80    #[must_use]
81    pub const fn loc(&self) -> &RepoLoc {
82        &self.loc
83    }
84
85    /// The origin URL of the repository, if specified.
86    #[inline]
87    #[must_use]
88    pub fn origin(&self) -> Option<&str> {
89        self.origin.as_deref()
90    }
91
92    /// The Debian release that this repository represents.
93    #[inline]
94    #[must_use]
95    pub const fn release(&self) -> &HashMap<char, String> {
96        &self.release
97    }
98
99    /// Apply an attribute to the repository, modifying its properties.
100    fn apply_attr(self, attr: RepoAttr) -> Self {
101        match attr {
102            RepoAttr::Origin(origin) => Self {
103                origin: Some(origin),
104                ..self
105            },
106            RepoAttr::Release(attrs) => Self {
107                release: attrs,
108                ..self
109            },
110        }
111    }
112}
113
114/// A package pinned to a specific version.
115#[derive(Debug)]
116pub struct PinnedPackage {
117    /// The package name.
118    name: String,
119
120    /// The version the package has been pinned to.
121    version: String,
122
123    /// The pin priority.
124    prio: i32,
125}
126
127impl PinnedPackage {
128    /// The pin priority.
129    #[inline]
130    #[must_use]
131    pub const fn prio(&self) -> i32 {
132        self.prio
133    }
134
135    /// The package name.
136    #[inline]
137    #[must_use]
138    pub fn name(&self) -> &str {
139        &self.name
140    }
141
142    /// The version the package has been pinned to.
143    #[inline]
144    #[must_use]
145    pub fn version(&self) -> &str {
146        &self.version
147    }
148}
149
150/// A single available version for an individual package.
151#[derive(Debug)]
152pub struct PackageVersion {
153    /// Is this version marked as the installed one?
154    installed: bool,
155
156    /// The package version.
157    version: String,
158
159    /// The pin priority.
160    prio: i32,
161
162    /// The locations that this version may be found at.
163    locations: Vec<RepoLoc>,
164}
165
166impl PackageVersion {
167    /// Is this version marked as the installed one?
168    #[inline]
169    #[must_use]
170    pub const fn installed(&self) -> bool {
171        self.installed
172    }
173
174    /// The package version.
175    #[inline]
176    #[must_use]
177    pub fn version(&self) -> &str {
178        &self.version
179    }
180
181    /// The pin priority.
182    #[inline]
183    #[must_use]
184    pub const fn prio(&self) -> i32 {
185        self.prio
186    }
187
188    /// The locations that this version may be found at.
189    #[inline]
190    #[must_use]
191    pub fn locations(&self) -> &[RepoLoc] {
192        &self.locations
193    }
194}
195
196/// The policy data of an individual package.
197#[derive(Debug)]
198pub struct PackagePolicy {
199    /// The package name.
200    name: String,
201
202    /// The installed version, if any.
203    installed: Option<String>,
204
205    /// The candidate version, if any.
206    candidate: Option<String>,
207
208    /// The available package versions.
209    versions: Vec<PackageVersion>,
210}
211
212impl PackagePolicy {
213    /// The package name.
214    #[inline]
215    #[must_use]
216    pub fn name(&self) -> &str {
217        &self.name
218    }
219
220    /// The candidate version, if any.
221    #[inline]
222    #[must_use]
223    pub fn candidate(&self) -> Option<&str> {
224        self.candidate.as_deref()
225    }
226
227    /// The installed version, if any.
228    #[inline]
229    #[must_use]
230    pub fn installed(&self) -> Option<&str> {
231        self.installed.as_deref()
232    }
233
234    /// The available package versions.
235    #[inline]
236    #[must_use]
237    pub fn versions(&self) -> &[PackageVersion] {
238        &self.versions
239    }
240}
241
242/// The parsed output of `apt-cache policy`.
243#[derive(Debug)]
244pub struct Policy {
245    /// The repositories to look for packages in.
246    repos: Option<Vec<Repo>>,
247
248    /// The pinned packages, if any.
249    pinned: Option<Vec<PinnedPackage>>,
250
251    /// The policy data of individual packages.
252    packages: Option<Vec<PackagePolicy>>,
253}
254
255impl Policy {
256    /// The repositories to look for packages in.
257    #[inline]
258    #[must_use]
259    pub fn repos(&self) -> Option<&[Repo]> {
260        self.repos.as_deref()
261    }
262
263    /// The pinned packages, if any.
264    #[inline]
265    #[must_use]
266    pub fn pinned(&self) -> Option<&[PinnedPackage]> {
267        self.pinned.as_deref()
268    }
269
270    /// The policy data of individual packages.
271    #[inline]
272    #[must_use]
273    pub fn packages(&self) -> Option<&[PackagePolicy]> {
274        self.packages.as_deref()
275    }
276}
277
278/// Parse a local file as a repo location.
279fn p_loc_file(input: &str) -> IResult<&str, RepoLoc> {
280    let (r_input, path) = preceded(p_char('/'), many1(none_of(" \t\n")))(input)?;
281    Ok((
282        r_input,
283        RepoLoc::LocalFile(format!(
284            "/{path}",
285            path = path.into_iter().collect::<String>()
286        )),
287    ))
288}
289
290/// Parse a URL pointing to a remote repository.
291fn p_loc_url(input: &str) -> IResult<&str, RepoLoc> {
292    let (r_input, (url, suite, component, arch_opt)) = terminated(
293        tuple((
294            many1(none_of(" \t\n")),
295            preceded(tag(" "), many1(none_of(" \t\n/"))),
296            preceded(tag("/"), many0(none_of(" \t\n"))),
297            opt(preceded(
298                tag(" "),
299                tuple((one_of("abcdefghijklmnopqrstuvwxyz"), alphanumeric0)),
300            )),
301        )),
302        tuple((tag(" "), tag("Packages"))),
303    )(input)?;
304    Ok((
305        r_input,
306        RepoLoc::Url {
307            url: url.into_iter().collect(),
308            suite: suite.into_iter().collect(),
309            component: component.into_iter().collect(),
310            arch: arch_opt.map(|(arch_first, arch_rest)| format!("{arch_first}{arch_rest}")),
311        },
312    ))
313}
314
315/// Parse the location of a repository.
316fn p_loc(input: &str) -> IResult<&str, RepoLoc> {
317    alt((p_loc_url, p_loc_file))(input)
318}
319
320/// Parse the first line of a repository definition.
321fn p_repo_line_start(input: &str) -> IResult<&str, Repo> {
322    let (r_input, (prio, loc)) =
323        delimited(space1, tuple((p_i32, preceded(tag(" "), p_loc))), tag("\n"))(input)?;
324    Ok((
325        r_input,
326        Repo {
327            prio,
328            loc,
329            origin: None,
330            release: HashMap::new(),
331        },
332    ))
333}
334
335/// Parse a single attribute of a "release" repo line.
336fn p_repo_line_attr_release_attr(input: &str) -> IResult<&str, (char, String)> {
337    let (r_input, (key, value)) = tuple((
338        one_of("abcdefghijklmnopqrstuvwxyz"),
339        preceded(tag("="), many0(none_of("\n,"))),
340    ))(input)?;
341    Ok((r_input, (key, value.into_iter().collect())))
342}
343
344/// Parse the "release" line of a repository.
345fn p_repo_line_attr_release(input: &str) -> IResult<&str, RepoAttr> {
346    let (r_input, attrs) = delimited(
347        tag("     release "),
348        separated_list1(tag(","), p_repo_line_attr_release_attr),
349        tag("\n"),
350    )(input)?;
351    Ok((r_input, RepoAttr::Release(attrs.into_iter().collect())))
352}
353
354/// Parse the "origin" line of a repository.
355fn p_repo_line_attr_origin(input: &str) -> IResult<&str, RepoAttr> {
356    let (r_input, origin) = delimited(tag("     origin"), many1(none_of("\n")), tag("\n"))(input)?;
357    Ok((r_input, RepoAttr::Origin(origin.into_iter().collect())))
358}
359
360/// Parse the attributes of a single repository.
361fn p_repo_line_attr(input: &str) -> IResult<&str, RepoAttr> {
362    alt((p_repo_line_attr_release, p_repo_line_attr_origin))(input)
363}
364
365/// Parse a single repository definition.
366fn p_repo(input: &str) -> IResult<&str, Repo> {
367    let (r_input, (start, attrs)) = tuple((p_repo_line_start, many0(p_repo_line_attr)))(input)?;
368    Ok((r_input, attrs.into_iter().fold(start, Repo::apply_attr)))
369}
370
371/// Parse a single pinned package line.
372fn p_pinned_pkg(input: &str) -> IResult<&str, PinnedPackage> {
373    let (r_input, (name, version, prio)) = tuple((
374        preceded(tag("     "), many1(none_of(" \t\n"))),
375        preceded(tag(" -> "), many1(none_of(" \t\n"))),
376        delimited(tag(" with priority "), p_i32, tag("\n")),
377    ))(input)?;
378    Ok((
379        r_input,
380        PinnedPackage {
381            name: name.into_iter().collect(),
382            version: version.into_iter().collect(),
383            prio,
384        },
385    ))
386}
387
388/// Parse a single package version data.
389fn p_pkg_version(input: &str) -> IResult<&str, PackageVersion> {
390    let (r_input, (installed, version, prio, locations)) = tuple((
391        preceded(tag(" "), alt((tag("   "), tag("***")))),
392        preceded(tag(" "), many1(none_of(" \t\n"))),
393        delimited(tag(" "), p_i32, tag("\n")),
394        many1(delimited(
395            tuple((space1, p_i32, tag(" "))),
396            p_loc,
397            tag("\n"),
398        )),
399    ))(input)?;
400    Ok((
401        r_input,
402        PackageVersion {
403            installed: installed == "***",
404            version: version.into_iter().collect(),
405            prio,
406            locations,
407        },
408    ))
409}
410
411/// Parse the policy for an individual package.
412fn p_pkg(input: &str) -> IResult<&str, PackagePolicy> {
413    let (r_input, (name, installed_or_not_v, candidate_or_not_v, versions)) = tuple((
414        terminated(many1(none_of(" \t\n:")), tag(":\n")),
415        delimited(tag("  Installed: "), many1(none_of(" \t\r\n")), tag("\n")),
416        delimited(tag("  Candidate: "), many1(none_of(" \t\r\n")), tag("\n")),
417        preceded(tag("  Version table:\n"), many0(p_pkg_version)),
418    ))(input)?;
419    let installed_or_not: String = installed_or_not_v.into_iter().collect();
420    let candidate_or_not: String = candidate_or_not_v.into_iter().collect();
421    Ok((
422        r_input,
423        PackagePolicy {
424            name: name.into_iter().collect(),
425            installed: (installed_or_not != "(none)").then_some(installed_or_not),
426            candidate: (candidate_or_not != "(none)").then_some(candidate_or_not),
427            versions,
428        },
429    ))
430}
431
432/// Parse the whole of the policy output.
433fn p_policy(input: &str) -> IResult<&str, Policy> {
434    let (r_input, (repos, pinned, packages)) = all_consuming(tuple((
435        opt(preceded(tag("Package files:\n"), many1(p_repo))),
436        opt(preceded(tag("Pinned packages:\n"), many0(p_pinned_pkg))),
437        opt(many1(p_pkg)),
438    )))(input)?;
439    Ok((
440        r_input,
441        Policy {
442            repos,
443            pinned,
444            packages,
445        },
446    ))
447}
448
449/// Convert a [`nom`] error to our own error type.
450fn nom_err_to_error(err: NErr<NError<&str>>) -> Error {
451    Error::ParsePolicy(anyhow!("{err}", err = err.map_input(ToOwned::to_owned)))
452}
453
454/// Parse the output of `apt-cache policy` out of a string.
455///
456/// # Errors
457///
458/// [`Error::NotImplemented`] and stuff.
459#[inline]
460pub fn from_str(value: &str) -> Result<Policy, Error> {
461    match p_policy(value) {
462        Ok((_, res)) => Ok(res),
463        Err(err) => Err(nom_err_to_error(err)),
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    #![allow(clippy::panic_in_result_fn)]
470    #![allow(clippy::print_stdout)]
471    #![allow(clippy::unwrap_used)]
472
473    use std::fs;
474
475    use anyhow::{anyhow, Context as _, Result};
476    use rstest::rstest;
477
478    use super::{PackageVersion, RepoLoc};
479
480    const P_LOC_FILE_DPKG_STATUS: &str = "/var/lib/dpkg/status";
481    const P_LOC_FILE_DPKG_STATUS_EXTRA: &str = "/var/lib/dpkg/status Packages";
482    const P_LOC_URL_LOCALHOST: &str = "http://127.0.0.1:9999/debian sid/main amd64 Packages";
483    const P_LOC_URL_SIMPLE: &str = "http://deb.debian.org/debian bullseye/non-free amd64 Packages";
484    const P_LOC_URL_WEIRD: &str = "https://mega.nz/linux/repo/Debian_testing ./ Packages";
485
486    #[rstest]
487    #[case::trivial("small-localhost")]
488    #[case::usual("bullseye")]
489    #[case::complex("large")]
490    #[case::packages("base-files-lintian")]
491    fn test_policy_good(#[case] basename: &str) -> Result<()> {
492        let filename = format!("test-data/policy-{basename}.txt");
493        let contents =
494            fs::read_to_string(&filename).with_context(|| format!("Could not read {filename}"))?;
495        super::from_str(&contents).with_context(|| format!("Could not parse {filename}"))?;
496        Ok(())
497    }
498
499    #[rstest]
500    #[case::file(P_LOC_FILE_DPKG_STATUS)]
501    fn test_loc_file_good(#[case] filename: &str) -> Result<()> {
502        match super::p_loc_file(filename) {
503            Ok(("", RepoLoc::LocalFile(path))) if path == filename => Ok(()),
504            huh => Err(anyhow!("{huh:?}")),
505        }
506    }
507
508    #[rstest]
509    #[case::extra(P_LOC_FILE_DPKG_STATUS_EXTRA)]
510    #[case::url_localhost(P_LOC_URL_LOCALHOST)]
511    #[case::url_simple(P_LOC_URL_SIMPLE)]
512    #[case::url_weird(P_LOC_URL_WEIRD)]
513    fn test_loc_file_bad(#[case] filename: &str) -> Result<()> {
514        match super::p_loc_file(filename) {
515            Ok((r_input, RepoLoc::LocalFile(path))) if format!("{path}{r_input}") == filename => {
516                Ok(())
517            }
518            Ok(huh) => Err(anyhow!("{huh:?}")),
519            Err(_) => Ok(()),
520        }
521    }
522
523    #[rstest]
524    #[case::localhost(P_LOC_URL_LOCALHOST)]
525    #[case::simple(P_LOC_URL_SIMPLE)]
526    #[case::weird(P_LOC_URL_WEIRD)]
527    fn test_loc_url_good(#[case] line: &str) -> Result<()> {
528        match super::p_loc_url(line) {
529            Ok(("", RepoLoc::Url { .. })) => Ok(()),
530            huh => Err(anyhow!("{huh:?}")),
531        }
532    }
533
534    #[rstest]
535    #[case::file(P_LOC_FILE_DPKG_STATUS)]
536    #[case::empty("")]
537    fn test_loc_url_bad(#[case] line: &str) -> Result<()> {
538        super::p_loc_url(line).map_or_else(|_| Ok(()), |huh| Err(anyhow!("{huh:?}")))
539    }
540
541    #[rstest]
542    #[case(false, "3.0a-2", -1_i32)]
543    #[case(true, "3.3a-5", 990_i32)]
544    #[case(false, "13ubuntu8", -1_i32)]
545    fn test_pkg_version_good(
546        #[case] exp_installed: bool,
547        #[case] exp_version: &str,
548        #[case] exp_prio: i32,
549    ) -> Result<()> {
550        let line = format!(
551            " {exp_installed} {exp_version} {exp_prio}\n     {exp_prio} {loc}\n",
552            exp_installed = if exp_installed { "***" } else { "   " },
553            loc = P_LOC_FILE_DPKG_STATUS
554        );
555        match super::p_pkg_version(&line) {
556            Ok((
557                "",
558                PackageVersion {
559                    installed,
560                    version,
561                    prio,
562                    locations,
563                },
564            )) if installed == exp_installed
565                && version == exp_version
566                && prio == exp_prio
567                && locations == [RepoLoc::LocalFile(P_LOC_FILE_DPKG_STATUS.to_owned())] =>
568            {
569                Ok(())
570            }
571            huh => Err(anyhow!("{huh:?}")),
572        }
573    }
574}