Skip to main content

cnf_lib/provider/
dnf.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// SPDX-FileCopyrightText: (C) 2023 Andreas Hartmann <hartan@7x.de>
3// This file is part of cnf-lib, available at <https://gitlab.com/hartang/rust/cnf>
4
5//! # Search packages with DNF
6//!
7//! DNF is the default package manager on RHEL-based Linux distributions. It works here roughly
8//! like this:
9//!
10//! - Query the system package for what provides "command"
11//! - Try to update the system cache if it doesn't exist
12//! - Fall back to searching as regular user for "command"
13//!
14//! This way, as long as `dnf` is installed, it should always perform a lookup, although it may
15//! come up empty.
16use crate::provider::prelude::*;
17use futures::StreamExt;
18
19/// Provider for the `dnf` package manager.
20#[derive(Default, Debug, PartialEq)]
21// In the future these may get (mutable) internal state.
22#[allow(missing_copy_implementations)]
23pub struct Dnf;
24
25/// Specific DNF version in use.
26#[derive(Debug, PartialEq)]
27enum DnfVersion {
28    /// DNF with major version 4.
29    DNF4,
30    /// DNF with major version 5.
31    DNF5,
32}
33
34impl fmt::Display for Dnf {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        write!(f, "DNF")
37    }
38}
39
40impl Dnf {
41    /// Create a new instance.
42    pub fn new() -> Self {
43        Default::default()
44    }
45
46    async fn get_version(&self, target_env: &Arc<Environment>) -> Result<DnfVersion, Error> {
47        let cmd = cmd!("dnf", "--version");
48        let output = target_env.output_of(cmd).await.map_err(Error::from)?;
49        let first_line = output.lines().next();
50        if first_line.is_some_and(|s| s.starts_with("dnf5")) {
51            Ok(DnfVersion::DNF5)
52        } else if first_line.is_some_and(|s| s.starts_with("4.")) {
53            Ok(DnfVersion::DNF4)
54        } else {
55            Err(Error::UnknownVersion(output))
56        }
57    }
58
59    /// Get a list of candidates from the raw stdout of `dnf provides`.
60    fn get_candidates_from_provides_output(&self, output: String) -> Vec<Candidate> {
61        let lines = output
62            .lines()
63            .map(|s| s.to_string())
64            .collect::<Vec<String>>();
65
66        let mut results = vec![];
67        let mut found_empty = true;
68        let mut candidate = Candidate::default();
69
70        for line in lines {
71            if line.is_empty() {
72                // Block processed
73                found_empty = true;
74                continue;
75            }
76
77            let (before, after) = match line.split_once(" : ") {
78                Some((a, b)) => (a.trim(), b.trim()),
79                None => {
80                    warn!("ignoring unexpected output from dnf: '{}'", line);
81                    continue;
82                }
83            };
84
85            if found_empty {
86                if !candidate.package.is_empty() {
87                    results.push(candidate);
88                }
89                candidate = Candidate::default();
90                candidate.package = before.to_string();
91                candidate.description = after.to_string();
92                candidate.actions.install = Some(cmd!("dnf", "install", "-y", before).privileged());
93                found_empty = false;
94            }
95            if before == "Repo" {
96                candidate.origin = after.to_string();
97            }
98            if before == "Provide" {
99                // There might be more in here
100                if let Some((package, version)) = after.split_once(" = ") {
101                    candidate.actions.execute = cmd!(package);
102                    candidate.version = version.to_string();
103                } else {
104                    candidate.actions.execute = cmd!(after);
105                }
106            }
107        }
108        results.push(candidate);
109
110        results
111    }
112
113    /// Check whether the given candidates are installed.
114    ///
115    /// Consumes the vector in the process and generates a new one with updated contents.
116    async fn check_installed(
117        &self,
118        target_env: &Arc<Environment>,
119        mut candidates: Vec<Candidate>,
120    ) -> Vec<Candidate> {
121        futures::stream::iter(candidates.iter_mut())
122            .for_each_concurrent(None, |candidate| {
123                async {
124                    let is_installed = target_env
125                        .output_of(cmd!("rpm", "-q", "--quiet", &candidate.package))
126                        .await
127                        .map(|_| true)
128                        .unwrap_or(false);
129
130                    if is_installed {
131                        candidate.actions.install = None;
132                    }
133                }
134                .in_current_span()
135            })
136            .await;
137
138        candidates
139    }
140
141    /// Search for the package providing the given command.
142    async fn search(
143        &self,
144        target_env: &Arc<Environment>,
145        command: &str,
146        refresh_cache: bool,
147    ) -> Result<String, Error> {
148        let mut cmd = if self.get_version(target_env).await? == DnfVersion::DNF4 {
149            cmd!("dnf", "-q", "--color", "never", "provides", command)
150        } else {
151            cmd!("dnf", "-q", "provides", command)
152        };
153
154        if refresh_cache {
155            cmd.append(&["--refresh"]);
156        } else {
157            cmd.append(&["-C"]);
158        }
159
160        target_env.output_of(cmd).await.map_err(Error::from)
161    }
162}
163
164#[async_trait]
165impl IsProvider for Dnf {
166    async fn search_internal(
167        &self,
168        command: &str,
169        target_env: Arc<Environment>,
170    ) -> ProviderResult<Vec<Candidate>> {
171        let stdout = match self.search(&target_env, command, false).await {
172            Ok(val) => val,
173            Err(Error::NoCache) => {
174                info!("dnf cache is outdated, trying to refresh");
175                self.search(&target_env, command, true).await
176            }
177            .map_err(|err| err.into_provider(command))?,
178            Err(err) => return Err(err.into_provider(command)),
179        };
180
181        let candidates = self.get_candidates_from_provides_output(stdout);
182        let mut candidates = self.check_installed(&target_env, candidates).await;
183        candidates.iter_mut().for_each(|candidate| {
184            if candidate.actions.execute.is_empty() {
185                candidate.actions.execute = cmd!(command.to_string());
186            }
187        });
188
189        Ok(candidates)
190    }
191}
192
193/// Errors from `dnf` interactions.
194#[derive(Debug, ThisError, Display)]
195pub enum Error {
196    /// command not found
197    NotFound,
198
199    /// cannot query packages, please update system (root) cache
200    Cache(#[from] CacheError),
201
202    /// no package cache present, please update system cache (as root user)
203    NoCache,
204
205    /// '{0}' must be installed to use this provider
206    Requirements(String),
207
208    /// cannot determine DNF version from output: {0}
209    UnknownVersion(String),
210
211    /// unexpected error occured during execution
212    Execution(ExecutionError),
213}
214
215#[derive(Debug, ThisError, Display)]
216/// failed to update dnf system cache
217pub struct CacheError(#[from] ExecutionError);
218
219impl Error {
220    /// Convert this error into a [`ProviderError`] instance.
221    pub fn into_provider(self, command: &str) -> ProviderError {
222        match self {
223            Self::NotFound => ProviderError::NotFound(command.to_string()),
224            Self::Requirements(what) => ProviderError::Requirements(what),
225            _ => ProviderError::ApplicationError(anyhow::Error::new(self)),
226        }
227    }
228}
229
230impl From<ExecutionError> for Error {
231    fn from(value: ExecutionError) -> Self {
232        match value {
233            ExecutionError::NonZero { ref output, .. } => {
234                let matcher = OutputMatcher::from(output);
235
236                if matcher.starts_with("Error: No matches found")
237                    // For DNF5
238                    || matcher.starts_with("No matches found")
239                {
240                    Error::NotFound
241                } else if matcher.starts_with("Error: Cache-only enabled but no cache")
242                    // For DNF5
243                    || matcher.starts_with("Cache-only enabled but no cache")
244                {
245                    Error::NoCache
246                } else {
247                    Error::Execution(value)
248                }
249            }
250            ExecutionError::NotFound(cmd) => Error::Requirements(cmd),
251            _ => Error::Execution(value),
252        }
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use crate::test::prelude::*;
260
261    #[test]
262    fn initialize() {
263        let _dnf = Dnf::new();
264    }
265
266    fn dnf4_version_output() -> String {
267        r#"4.14.0
268  Installed: dnf-0:4.21.1-1.fc39.noarch at Tue Nov 26 07:51:32 2024
269  Built    : Fedora Project at Sat Aug 17 03:55:02 2024
270
271  Installed: rpm-0:4.19.1.1-1.fc39.x86_64 at Tue Nov 26 07:51:32 2024
272  Built    : Fedora Project at Wed Feb  7 16:05:57 2024"#
273            .to_string()
274    }
275
276    test::default_tests!(Dnf::new());
277
278    /// Searching without system cache
279    ///
280    /// - Searched with: dnf 4.14.0
281    /// - Search command: "dnf -q -C --color never provides asdwasda"
282    /// - Remaining outputs taken from `matches_htop` test
283    #[test]
284    fn cache_empty() {
285        let query = quick_test!(
286            Dnf::new(),
287            Ok(dnf4_version_output()),
288            Err(ExecutionError::NonZero {
289                command: "dnf".to_string(),
290                output: std::process::Output {
291                    stdout: r"".into(),
292                    stderr: r#"Error: Cache-only enabled but no cache for 'fedora'"#.into(),
293                    status: ExitStatus::from_raw(1),
294                },
295            }),
296            Ok(dnf4_version_output()),
297            Ok("
298htop-3.2.1-2.fc37.x86_64 : Interactive process viewer
299Repo        : fedora
300Matched from:
301Provide    : htop = 3.2.1-2.fc37
302
303htop-3.2.2-2.fc37.x86_64 : Interactive process viewer
304Repo        : updates
305Matched from:
306Provide    : htop = 3.2.2-2.fc37
307"
308            .to_string()),
309            // Already installed
310            Ok("".to_string()),
311            // Already installed
312            Ok("".to_string())
313        );
314
315        let result = query.results.expect("expected successful results");
316        assert_eq!(result.len(), 2);
317        assert!(result[0].package.starts_with("htop-3.2.1-2"));
318    }
319
320    /// Searching nonexistent package
321    ///
322    /// - Searched with: dnf 4.14.0
323    /// - Search command: "dnf -q -C --color never provides asdwasda"
324    #[test]
325    fn search_nonexistent() {
326        let query = quick_test!(
327            Dnf::new(),
328            Ok(dnf4_version_output()),
329            Err(ExecutionError::NonZero {
330                command: "dnf".to_string(),
331                output: std::process::Output {
332                    stdout: r"".into(),
333                    stderr: r#"Error: No matches found. If searching for a file, try specifying the full path or using a wildcard prefix ("*/") at the beginning."#.into(),
334                    status: ExitStatus::from_raw(1),
335                },
336            })
337        );
338
339        assert::is_err!(query);
340        assert::err::not_found!(query);
341    }
342
343    /// Searching existing package htop
344    ///
345    /// - Searched with: dnf 4.14.0
346    /// - Search command: "dnf -q -C --color never provides htop"
347    #[test]
348    fn matches_htop() {
349        let query = quick_test!(
350            Dnf::new(),
351            Ok(dnf4_version_output()),
352            Ok("
353htop-3.2.1-2.fc37.x86_64 : Interactive process viewer
354Repo        : fedora
355Matched from:
356Provide    : htop = 3.2.1-2.fc37
357
358htop-3.2.2-2.fc37.x86_64 : Interactive process viewer
359Repo        : updates
360Matched from:
361Provide    : htop = 3.2.2-2.fc37
362"
363            .to_string()),
364            // This result is not installable (already installed)
365            Ok("".to_string()),
366            // This result *is* installable
367            Err(ExecutionError::NonZero {
368                command: "rpm".to_string(),
369                output: std::process::Output {
370                    stdout: r"".into(),
371                    stderr: r"".into(),
372                    status: ExitStatus::from_raw(1),
373                },
374            })
375        );
376
377        let result = query.results.unwrap();
378
379        assert_eq!(result.len(), 2);
380        assert!(result[0].package.starts_with("htop-3.2.1-2"));
381        assert_eq!(result[0].version, "3.2.1-2.fc37");
382        assert_eq!(result[0].origin, "fedora");
383        assert_eq!(result[0].description, "Interactive process viewer");
384        assert_eq!(result[0].actions.execute, vec!["htop"].into());
385        assert_eq!(result[1].description, "Interactive process viewer");
386
387        let num_installable = result
388            .iter()
389            .fold(0, |acc, c| acc + (c.actions.install.is_some() as usize));
390        assert_eq!(num_installable, 1);
391    }
392
393    /// Searching existing binary 'ping'
394    ///
395    /// - Searched with: dnf 4.14.0
396    /// - Search command: "dnf -q -C --color never provides ping"
397    #[test]
398    fn matches_ping() {
399        let query = quick_test!(
400            Dnf::new(),
401            Ok(dnf4_version_output()),
402            Ok("
403iputils-20211215-3.fc37.x86_64 : Network monitoring tools including ping
404Repo        : fedora
405Matched from:
406Filename    : /usr/bin/ping
407Provide    : /bin/ping
408Filename    : /usr/sbin/ping
409
410iputils-20221126-1.fc37.x86_64 : Network monitoring tools including ping
411Repo        : @System
412Matched from:
413Filename    : /usr/bin/ping
414Provide    : /bin/ping
415Filename    : /usr/sbin/ping
416
417iputils-20221126-1.fc37.x86_64 : Network monitoring tools including ping
418Repo        : updates
419Matched from:
420Filename    : /usr/bin/ping
421Provide    : /bin/ping
422Filename    : /usr/sbin/ping
423"
424            .to_string()),
425            // First one is installed
426            Ok("".to_string()),
427            // Second isn't
428            Err(ExecutionError::NonZero {
429                command: "rpm".to_string(),
430                output: std::process::Output {
431                    stdout: r"".into(),
432                    stderr: r"".into(),
433                    status: ExitStatus::from_raw(1),
434                },
435            }),
436            // Third one throws confusing error (not installed)
437            Err(ExecutionError::NotFound("rpm".to_string()))
438        );
439
440        let result = query.results.unwrap();
441
442        assert!(result.len() == 3);
443        assert!(result[0].package.starts_with("iputils"));
444        assert!(result[0].version.is_empty());
445        assert!(result[0].origin == "fedora");
446        assert!(result[1].origin == "@System");
447        assert!(result[0].description == "Network monitoring tools including ping");
448        assert!(result[0].actions.execute == vec!["/bin/ping"].into());
449        assert!(result[1].description == "Network monitoring tools including ping");
450
451        let num_installable = result
452            .iter()
453            .fold(0, |acc, c| acc + (c.actions.install.is_some() as usize));
454        assert_eq!(num_installable, 2);
455    }
456
457    #[test]
458    // This one killed an earlier version of the package parsing code which split lines at ':', due
459    // to the ':' in the application version number.
460    fn matches_nmap() {
461        let query = quick_test!(
462            Dnf::new(),
463            Ok(dnf4_version_output()),
464            Ok("
465nmap-3:7.93-2.fc38.x86_64 : Network exploration tool and security scanner
466Repo        : fedora
467Matched from:
468Provide    : nmap = 3:7.93-2.fc38
469"
470            .to_string()),
471            // Not installed
472            Err(ExecutionError::NonZero {
473                command: "rpm".to_string(),
474                output: std::process::Output {
475                    stdout: r"".into(),
476                    stderr: r"".into(),
477                    status: ExitStatus::from_raw(1),
478                },
479            })
480        );
481
482        let result = query.results.unwrap();
483
484        assert_eq!(result.len(), 1);
485        assert!(result[0].package.starts_with("nmap"));
486        assert_eq!(result[0].version, "3:7.93-2.fc38");
487        assert_eq!(result[0].origin, "fedora");
488        assert_eq!(
489            result[0].description,
490            "Network exploration tool and security scanner"
491        );
492        assert_eq!(result[0].actions.execute, vec!["nmap"].into());
493    }
494}