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        user_only: 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        if !user_only {
154            cmd.append(&["-C"]);
155        }
156
157        target_env.output_of(cmd).await.map_err(Error::from)
158    }
159
160    /// Try to update the system package cache.
161    async fn update_cache(&self, target_env: &Arc<Environment>) -> Result<(), CacheError> {
162        target_env
163            .output_of(cmd!("dnf", "makecache", "-q", "--color", "never").privileged())
164            .await
165            .map(|_| ())
166            .map_err(CacheError::from)
167    }
168}
169
170#[async_trait]
171impl IsProvider for Dnf {
172    async fn search_internal(
173        &self,
174        command: &str,
175        target_env: Arc<Environment>,
176    ) -> ProviderResult<Vec<Candidate>> {
177        let stdout = match self.search(&target_env, command, false).await {
178            Ok(val) => val,
179            Err(Error::NoCache) => {
180                info!("dnf cache is outdated, trying to update");
181                let success = self.update_cache(&target_env).await.is_ok();
182                self.search(&target_env, command, !success).await
183            }
184            .map_err(|err| err.into_provider(command))?,
185            Err(err) => return Err(err.into_provider(command)),
186        };
187
188        let candidates = self.get_candidates_from_provides_output(stdout);
189        let mut candidates = self.check_installed(&target_env, candidates).await;
190        candidates.iter_mut().for_each(|candidate| {
191            if candidate.actions.execute.is_empty() {
192                candidate.actions.execute = cmd!(command.to_string());
193            }
194        });
195
196        Ok(candidates)
197    }
198}
199
200/// Errors from `dnf` interactions.
201#[derive(Debug, ThisError, Display)]
202pub enum Error {
203    /// command not found
204    NotFound,
205
206    /// cannot query packages, please update system (root) cache
207    Cache(#[from] CacheError),
208
209    /// no package cache present, please update system cache (as root user)
210    NoCache,
211
212    /// '{0}' must be installed to use this provider
213    Requirements(String),
214
215    /// cannot determine DNF version from output: {0}
216    UnknownVersion(String),
217
218    /// unexpected error occured during execution
219    Execution(ExecutionError),
220}
221
222#[derive(Debug, ThisError, Display)]
223/// failed to update dnf system cache
224pub struct CacheError(#[from] ExecutionError);
225
226impl Error {
227    /// Convert this error into a [`ProviderError`] instance.
228    pub fn into_provider(self, command: &str) -> ProviderError {
229        match self {
230            Self::NotFound => ProviderError::NotFound(command.to_string()),
231            Self::Requirements(what) => ProviderError::Requirements(what),
232            _ => ProviderError::ApplicationError(anyhow::Error::new(self)),
233        }
234    }
235}
236
237impl From<ExecutionError> for Error {
238    fn from(value: ExecutionError) -> Self {
239        match value {
240            ExecutionError::NonZero { ref output, .. } => {
241                let matcher = OutputMatcher::from(output);
242
243                if matcher.starts_with("Error: No matches found")
244                    // For DNF5
245                    || matcher.starts_with("No matches found")
246                {
247                    Error::NotFound
248                } else if matcher.starts_with("Error: Cache-only enabled but no cache")
249                    // For DNF5
250                    || matcher.starts_with("Cache-only enabled but no cache")
251                {
252                    Error::NoCache
253                } else {
254                    Error::Execution(value)
255                }
256            }
257            ExecutionError::NotFound(cmd) => Error::Requirements(cmd),
258            _ => Error::Execution(value),
259        }
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use crate::test::prelude::*;
267
268    #[test]
269    fn initialize() {
270        let _dnf = Dnf::new();
271    }
272
273    fn dnf4_version_output() -> String {
274        r#"4.14.0
275  Installed: dnf-0:4.21.1-1.fc39.noarch at Tue Nov 26 07:51:32 2024
276  Built    : Fedora Project at Sat Aug 17 03:55:02 2024
277
278  Installed: rpm-0:4.19.1.1-1.fc39.x86_64 at Tue Nov 26 07:51:32 2024
279  Built    : Fedora Project at Wed Feb  7 16:05:57 2024"#
280            .to_string()
281    }
282
283    test::default_tests!(Dnf::new());
284
285    /// Searching without system cache
286    ///
287    /// - Searched with: dnf 4.14.0
288    /// - Search command: "dnf -q -C --color never provides asdwasda"
289    /// - Remaining outputs taken from `matches_htop` test
290    #[test]
291    fn cache_empty() {
292        let query = quick_test!(
293            Dnf::new(),
294            Ok(dnf4_version_output()),
295            Err(ExecutionError::NonZero {
296                command: "dnf".to_string(),
297                output: std::process::Output {
298                    stdout: r"".into(),
299                    stderr: r#"Error: Cache-only enabled but no cache for 'fedora'"#.into(),
300                    status: ExitStatus::from_raw(1),
301                },
302            }),
303            Err(ExecutionError::NotFound("dnf".to_string())),
304            Ok(dnf4_version_output()),
305            Ok("
306htop-3.2.1-2.fc37.x86_64 : Interactive process viewer
307Repo        : fedora
308Matched from:
309Provide    : htop = 3.2.1-2.fc37
310
311htop-3.2.2-2.fc37.x86_64 : Interactive process viewer
312Repo        : updates
313Matched from:
314Provide    : htop = 3.2.2-2.fc37
315"
316            .to_string()),
317            // Already installed
318            Ok("".to_string()),
319            // Already installed
320            Ok("".to_string())
321        );
322
323        let result = query.results.expect("expected successful results");
324        assert_eq!(result.len(), 2);
325        assert!(result[0].package.starts_with("htop-3.2.1-2"));
326    }
327
328    /// Searching nonexistent package
329    ///
330    /// - Searched with: dnf 4.14.0
331    /// - Search command: "dnf -q -C --color never provides asdwasda"
332    #[test]
333    fn search_nonexistent() {
334        let query = quick_test!(
335            Dnf::new(),
336            Ok(dnf4_version_output()),
337            Err(ExecutionError::NonZero {
338                command: "dnf".to_string(),
339                output: std::process::Output {
340                    stdout: r"".into(),
341                    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(),
342                    status: ExitStatus::from_raw(1),
343                },
344            })
345        );
346
347        assert::is_err!(query);
348        assert::err::not_found!(query);
349    }
350
351    /// Searching existing package htop
352    ///
353    /// - Searched with: dnf 4.14.0
354    /// - Search command: "dnf -q -C --color never provides htop"
355    #[test]
356    fn matches_htop() {
357        let query = quick_test!(
358            Dnf::new(),
359            Ok(dnf4_version_output()),
360            Ok("
361htop-3.2.1-2.fc37.x86_64 : Interactive process viewer
362Repo        : fedora
363Matched from:
364Provide    : htop = 3.2.1-2.fc37
365
366htop-3.2.2-2.fc37.x86_64 : Interactive process viewer
367Repo        : updates
368Matched from:
369Provide    : htop = 3.2.2-2.fc37
370"
371            .to_string()),
372            // This result is not installable (already installed)
373            Ok("".to_string()),
374            // This result *is* installable
375            Err(ExecutionError::NonZero {
376                command: "rpm".to_string(),
377                output: std::process::Output {
378                    stdout: r"".into(),
379                    stderr: r"".into(),
380                    status: ExitStatus::from_raw(1),
381                },
382            })
383        );
384
385        let result = query.results.unwrap();
386
387        assert_eq!(result.len(), 2);
388        assert!(result[0].package.starts_with("htop-3.2.1-2"));
389        assert_eq!(result[0].version, "3.2.1-2.fc37");
390        assert_eq!(result[0].origin, "fedora");
391        assert_eq!(result[0].description, "Interactive process viewer");
392        assert_eq!(result[0].actions.execute, vec!["htop"].into());
393        assert_eq!(result[1].description, "Interactive process viewer");
394
395        let num_installable = result
396            .iter()
397            .fold(0, |acc, c| acc + (c.actions.install.is_some() as usize));
398        assert_eq!(num_installable, 1);
399    }
400
401    /// Searching existing binary 'ping'
402    ///
403    /// - Searched with: dnf 4.14.0
404    /// - Search command: "dnf -q -C --color never provides ping"
405    #[test]
406    fn matches_ping() {
407        let query = quick_test!(
408            Dnf::new(),
409            Ok(dnf4_version_output()),
410            Ok("
411iputils-20211215-3.fc37.x86_64 : Network monitoring tools including ping
412Repo        : fedora
413Matched from:
414Filename    : /usr/bin/ping
415Provide    : /bin/ping
416Filename    : /usr/sbin/ping
417
418iputils-20221126-1.fc37.x86_64 : Network monitoring tools including ping
419Repo        : @System
420Matched from:
421Filename    : /usr/bin/ping
422Provide    : /bin/ping
423Filename    : /usr/sbin/ping
424
425iputils-20221126-1.fc37.x86_64 : Network monitoring tools including ping
426Repo        : updates
427Matched from:
428Filename    : /usr/bin/ping
429Provide    : /bin/ping
430Filename    : /usr/sbin/ping
431"
432            .to_string()),
433            // First one is installed
434            Ok("".to_string()),
435            // Second isn't
436            Err(ExecutionError::NonZero {
437                command: "rpm".to_string(),
438                output: std::process::Output {
439                    stdout: r"".into(),
440                    stderr: r"".into(),
441                    status: ExitStatus::from_raw(1),
442                },
443            }),
444            // Third one throws confusing error (not installed)
445            Err(ExecutionError::NotFound("rpm".to_string()))
446        );
447
448        let result = query.results.unwrap();
449
450        assert!(result.len() == 3);
451        assert!(result[0].package.starts_with("iputils"));
452        assert!(result[0].version.is_empty());
453        assert!(result[0].origin == "fedora");
454        assert!(result[1].origin == "@System");
455        assert!(result[0].description == "Network monitoring tools including ping");
456        assert!(result[0].actions.execute == vec!["/bin/ping"].into());
457        assert!(result[1].description == "Network monitoring tools including ping");
458
459        let num_installable = result
460            .iter()
461            .fold(0, |acc, c| acc + (c.actions.install.is_some() as usize));
462        assert_eq!(num_installable, 2);
463    }
464
465    #[test]
466    // This one killed an earlier version of the package parsing code which split lines at ':', due
467    // to the ':' in the application version number.
468    fn matches_nmap() {
469        let query = quick_test!(
470            Dnf::new(),
471            Ok(dnf4_version_output()),
472            Ok("
473nmap-3:7.93-2.fc38.x86_64 : Network exploration tool and security scanner
474Repo        : fedora
475Matched from:
476Provide    : nmap = 3:7.93-2.fc38
477"
478            .to_string()),
479            // Not installed
480            Err(ExecutionError::NonZero {
481                command: "rpm".to_string(),
482                output: std::process::Output {
483                    stdout: r"".into(),
484                    stderr: r"".into(),
485                    status: ExitStatus::from_raw(1),
486                },
487            })
488        );
489
490        let result = query.results.unwrap();
491
492        assert_eq!(result.len(), 1);
493        assert!(result[0].package.starts_with("nmap"));
494        assert_eq!(result[0].version, "3:7.93-2.fc38");
495        assert_eq!(result[0].origin, "fedora");
496        assert_eq!(
497            result[0].description,
498            "Network exploration tool and security scanner"
499        );
500        assert_eq!(result[0].actions.execute, vec!["nmap"].into());
501    }
502}