cnf_lib/provider/
dnf.rs

1// Copyright (C) 2023 Andreas Hartmann <hartan@7x.de>
2// GNU General Public License v3.0+ (https://www.gnu.org/licenses/gpl-3.0.txt)
3// SPDX-License-Identifier: GPL-3.0-or-later
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#[derive(Default, Debug, PartialEq)]
20pub struct Dnf;
21
22impl fmt::Display for Dnf {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        write!(f, "DNF")
25    }
26}
27
28impl Dnf {
29    /// Create a new instance.
30    pub fn new() -> Self {
31        Default::default()
32    }
33
34    /// Get a list of candidates from the raw stdout of `dnf provides`.
35    fn get_candidates_from_provides_output(&self, output: String) -> Vec<Candidate> {
36        let lines = output
37            .lines()
38            .map(|s| s.to_string())
39            .collect::<Vec<String>>();
40
41        let mut results = vec![];
42        let mut found_empty = true;
43        let mut candidate = Candidate::default();
44
45        for line in lines {
46            if line.is_empty() {
47                // Block processed
48                found_empty = true;
49                continue;
50            }
51
52            let (before, after) = match line.split_once(" : ") {
53                Some((a, b)) => (a.trim(), b.trim()),
54                None => {
55                    warn!("ignoring unexpected output from dnf: '{}'", line);
56                    continue;
57                }
58            };
59
60            if found_empty {
61                if !candidate.package.is_empty() {
62                    results.push(candidate);
63                }
64                candidate = Candidate::default();
65                candidate.package = before.to_string();
66                candidate.description = after.to_string();
67                candidate.actions.install = Some(cmd!("dnf", "install", "-y", before).privileged());
68                found_empty = false;
69            }
70            if before == "Repo" {
71                candidate.origin = after.to_string();
72            }
73            if before == "Provide" {
74                // There might be more in here
75                if let Some((package, version)) = after.split_once(" = ") {
76                    candidate.actions.execute = cmd!(package);
77                    candidate.version = version.to_string();
78                } else {
79                    candidate.actions.execute = cmd!(after);
80                }
81            }
82        }
83        results.push(candidate);
84
85        results
86    }
87
88    /// Check whether the given candidates are installed.
89    ///
90    /// Consumes the vector in the process and generates a new one with updated contents.
91    async fn check_installed(
92        &self,
93        target_env: &Arc<Environment>,
94        mut candidates: Vec<Candidate>,
95    ) -> Vec<Candidate> {
96        futures::stream::iter(candidates.iter_mut())
97            .for_each_concurrent(None, |candidate| {
98                async {
99                    let is_installed = target_env
100                        .output_of(cmd!("rpm", "-q", "--quiet", &candidate.package))
101                        .await
102                        .map(|_| true)
103                        .unwrap_or(false);
104
105                    if is_installed {
106                        candidate.actions.install = None;
107                    }
108                }
109                .in_current_span()
110            })
111            .await;
112
113        candidates
114    }
115
116    /// Search for the package providing the given command.
117    async fn search(
118        &self,
119        target_env: &Arc<Environment>,
120        command: &str,
121        user_only: bool,
122    ) -> Result<String, Error> {
123        let mut cmd = cmd!("dnf", "-q", "--color", "never", "provides", command);
124        if !user_only {
125            cmd.append(&["-C"]);
126        }
127
128        target_env.output_of(cmd).await.map_err(Error::from)
129    }
130
131    /// Try to update the system package cache.
132    async fn update_cache(&self, target_env: &Arc<Environment>) -> Result<(), CacheError> {
133        target_env
134            .output_of(cmd!("dnf", "makecache", "-q", "--color", "never").privileged())
135            .await
136            .map(|_| ())
137            .map_err(CacheError::from)
138    }
139}
140
141#[async_trait]
142impl IsProvider for Dnf {
143    async fn search_internal(
144        &self,
145        command: &str,
146        target_env: Arc<Environment>,
147    ) -> ProviderResult<Vec<Candidate>> {
148        let stdout = match self.search(&target_env, command, false).await {
149            Ok(val) => val,
150            Err(Error::NoCache) => {
151                info!("dnf cache is outdated, trying to update");
152                let success = self.update_cache(&target_env).await.is_ok();
153                self.search(&target_env, command, !success).await
154            }
155            .map_err(|err| err.into_provider(command))?,
156            Err(err) => return Err(err.into_provider(command)),
157        };
158
159        let candidates = self.get_candidates_from_provides_output(stdout);
160        let mut candidates = self.check_installed(&target_env, candidates).await;
161        candidates.iter_mut().for_each(|candidate| {
162            if candidate.actions.execute.is_empty() {
163                candidate.actions.execute = cmd!(command.to_string());
164            }
165        });
166
167        Ok(candidates)
168    }
169}
170
171#[derive(Debug, ThisError)]
172pub enum Error {
173    #[error("command not found")]
174    NotFound,
175
176    #[error("cannot query packages, please update system (root) cache")]
177    Cache(#[from] CacheError),
178
179    #[error("no package cache present, please update system cache (as root user)")]
180    NoCache,
181
182    #[error("'{0}' must be installed to use this provider")]
183    Requirements(String),
184
185    #[error(transparent)]
186    Execution(ExecutionError),
187}
188
189#[derive(Debug, ThisError)]
190#[error("failed to update dnf system cache")]
191pub struct CacheError(#[from] ExecutionError);
192
193impl Error {
194    pub fn into_provider(self, command: &str) -> ProviderError {
195        match self {
196            Self::NotFound => ProviderError::NotFound(command.to_string()),
197            Self::Requirements(what) => ProviderError::Requirements(what),
198            _ => ProviderError::ApplicationError(anyhow::Error::new(self)),
199        }
200    }
201}
202
203impl From<ExecutionError> for Error {
204    fn from(value: ExecutionError) -> Self {
205        if let ExecutionError::NonZero { ref output, .. } = value {
206            let matcher = OutputMatcher::from(output);
207
208            if matcher.starts_with("Error: No matches found") {
209                Error::NotFound
210            } else if matcher.starts_with("Error: Cache-only enabled but no cache") {
211                Error::NoCache
212            } else {
213                Error::Execution(value)
214            }
215        // FIXME(hartan): This relies entirely on the fact that all commands this provider executes
216        // call into `dnf`. This is needed because we cannot match against a `String` in the
217        // pattern, because there's no `String` literal syntax for patterns.
218        } else if matches!(value, ExecutionError::NotFound(_)) {
219            Error::Requirements("dnf".to_string())
220        } else {
221            Error::Execution(value)
222        }
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use crate::test::prelude::*;
230
231    #[test]
232    fn initialize() {
233        let _dnf = Dnf::new();
234    }
235
236    test::default_tests!(Dnf::new());
237
238    /// Searching without system cache
239    ///
240    /// - Searched with: dnf 4.14.0
241    /// - Search command: "dnf -q -C --color never provides asdwasda"
242    /// - Remaining outputs taken from `matches_htop` test
243    #[test]
244    fn cache_empty() {
245        let query = quick_test!(
246            Dnf::new(),
247            Err(ExecutionError::NonZero {
248                command: "dnf".to_string(),
249                output: std::process::Output {
250                    stdout: r"".into(),
251                    stderr: r#"Error: Cache-only enabled but no cache for 'fedora'"#.into(),
252                    status: ExitStatus::from_raw(1),
253                },
254            }),
255            Err(ExecutionError::NotFound("dnf".to_string())),
256            Ok("
257htop-3.2.1-2.fc37.x86_64 : Interactive process viewer
258Repo        : fedora
259Matched from:
260Provide    : htop = 3.2.1-2.fc37
261
262htop-3.2.2-2.fc37.x86_64 : Interactive process viewer
263Repo        : updates
264Matched from:
265Provide    : htop = 3.2.2-2.fc37
266"
267            .to_string()),
268            // Already installed
269            Ok("".to_string()),
270            // Already installed
271            Ok("".to_string())
272        );
273
274        let result = query.results.expect("expected successful results");
275        assert_eq!(result.len(), 2);
276        assert!(result[0].package.starts_with("htop-3.2.1-2"));
277    }
278
279    /// Searching nonexistent package
280    ///
281    /// - Searched with: dnf 4.14.0
282    /// - Search command: "dnf -q -C --color never provides asdwasda"
283    #[test]
284    fn search_nonexistent() {
285        let query = quick_test!(Dnf::new(), Err(ExecutionError::NonZero {
286            command: "dnf".to_string(),
287            output: std::process::Output {
288                stdout: r"".into(),
289                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(),
290                status: ExitStatus::from_raw(1),
291            },
292        }));
293
294        assert::is_err!(query);
295        assert::err::not_found!(query);
296    }
297
298    /// Searching existing package htop
299    ///
300    /// - Searched with: dnf 4.14.0
301    /// - Search command: "dnf -q -C --color never provides htop"
302    #[test]
303    fn matches_htop() {
304        let query = quick_test!(
305            Dnf::new(),
306            Ok("
307htop-3.2.1-2.fc37.x86_64 : Interactive process viewer
308Repo        : fedora
309Matched from:
310Provide    : htop = 3.2.1-2.fc37
311
312htop-3.2.2-2.fc37.x86_64 : Interactive process viewer
313Repo        : updates
314Matched from:
315Provide    : htop = 3.2.2-2.fc37
316"
317            .to_string()),
318            // This result is not installable (already installed)
319            Ok("".to_string()),
320            // This result *is* installable
321            Err(ExecutionError::NonZero {
322                command: "rpm".to_string(),
323                output: std::process::Output {
324                    stdout: r"".into(),
325                    stderr: r"".into(),
326                    status: ExitStatus::from_raw(1),
327                },
328            })
329        );
330
331        let result = query.results.unwrap();
332
333        assert_eq!(result.len(), 2);
334        assert!(result[0].package.starts_with("htop-3.2.1-2"));
335        assert_eq!(result[0].version, "3.2.1-2.fc37");
336        assert_eq!(result[0].origin, "fedora");
337        assert_eq!(result[0].description, "Interactive process viewer");
338        assert_eq!(result[0].actions.execute, vec!["htop"].into());
339        assert_eq!(result[1].description, "Interactive process viewer");
340
341        let num_installable = result
342            .iter()
343            .fold(0, |acc, c| acc + (c.actions.install.is_some() as usize));
344        assert_eq!(num_installable, 1);
345    }
346
347    /// Searching existing binary 'ping'
348    ///
349    /// - Searched with: dnf 4.14.0
350    /// - Search command: "dnf -q -C --color never provides ping"
351    #[test]
352    fn matches_ping() {
353        let query = quick_test!(
354            Dnf::new(),
355            Ok("
356iputils-20211215-3.fc37.x86_64 : Network monitoring tools including ping
357Repo        : fedora
358Matched from:
359Filename    : /usr/bin/ping
360Provide    : /bin/ping
361Filename    : /usr/sbin/ping
362
363iputils-20221126-1.fc37.x86_64 : Network monitoring tools including ping
364Repo        : @System
365Matched from:
366Filename    : /usr/bin/ping
367Provide    : /bin/ping
368Filename    : /usr/sbin/ping
369
370iputils-20221126-1.fc37.x86_64 : Network monitoring tools including ping
371Repo        : updates
372Matched from:
373Filename    : /usr/bin/ping
374Provide    : /bin/ping
375Filename    : /usr/sbin/ping
376"
377            .to_string()),
378            // First one is installed
379            Ok("".to_string()),
380            // Second isn't
381            Err(ExecutionError::NonZero {
382                command: "rpm".to_string(),
383                output: std::process::Output {
384                    stdout: r"".into(),
385                    stderr: r"".into(),
386                    status: ExitStatus::from_raw(1),
387                },
388            }),
389            // Third one throws confusing error (not installed)
390            Err(ExecutionError::NotFound("rpm".to_string()))
391        );
392
393        let result = query.results.unwrap();
394
395        assert!(result.len() == 3);
396        assert!(result[0].package.starts_with("iputils"));
397        assert!(result[0].version.is_empty());
398        assert!(result[0].origin == "fedora");
399        assert!(result[1].origin == "@System");
400        assert!(result[0].description == "Network monitoring tools including ping");
401        assert!(result[0].actions.execute == vec!["/bin/ping"].into());
402        assert!(result[1].description == "Network monitoring tools including ping");
403
404        let num_installable = result
405            .iter()
406            .fold(0, |acc, c| acc + (c.actions.install.is_some() as usize));
407        assert_eq!(num_installable, 2);
408    }
409
410    #[test]
411    // This one killed an earlier version of the package parsing code which split lines at ':', due
412    // to the ':' in the application version number.
413    fn matches_nmap() {
414        let query = quick_test!(
415            Dnf::new(),
416            Ok("
417nmap-3:7.93-2.fc38.x86_64 : Network exploration tool and security scanner
418Repo        : fedora
419Matched from:
420Provide    : nmap = 3:7.93-2.fc38
421"
422            .to_string()),
423            // Not installed
424            Err(ExecutionError::NonZero {
425                command: "rpm".to_string(),
426                output: std::process::Output {
427                    stdout: r"".into(),
428                    stderr: r"".into(),
429                    status: ExitStatus::from_raw(1),
430                },
431            })
432        );
433
434        let result = query.results.unwrap();
435
436        assert_eq!(result.len(), 1);
437        assert!(result[0].package.starts_with("nmap"));
438        assert_eq!(result[0].version, "3:7.93-2.fc38");
439        assert_eq!(result[0].origin, "fedora");
440        assert_eq!(
441            result[0].description,
442            "Network exploration tool and security scanner"
443        );
444        assert_eq!(result[0].actions.execute, vec!["nmap"].into());
445    }
446}