1use crate::provider::prelude::*;
17use futures::StreamExt;
18
19#[derive(Default, Debug, PartialEq)]
21#[allow(missing_copy_implementations)]
23pub struct Dnf;
24
25#[derive(Debug, PartialEq)]
27enum DnfVersion {
28 DNF4,
30 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 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 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 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 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 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 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#[derive(Debug, ThisError, Display)]
195pub enum Error {
196 NotFound,
198
199 Cache(#[from] CacheError),
201
202 NoCache,
204
205 Requirements(String),
207
208 UnknownVersion(String),
210
211 Execution(ExecutionError),
213}
214
215#[derive(Debug, ThisError, Display)]
216pub struct CacheError(#[from] ExecutionError);
218
219impl Error {
220 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 || matcher.starts_with("No matches found")
239 {
240 Error::NotFound
241 } else if matcher.starts_with("Error: Cache-only enabled but no cache")
242 || 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 #[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 Ok("".to_string()),
311 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 #[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 #[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 Ok("".to_string()),
366 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 #[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 Ok("".to_string()),
427 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 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 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 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}