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 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 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#[derive(Debug, ThisError, Display)]
202pub enum Error {
203 NotFound,
205
206 Cache(#[from] CacheError),
208
209 NoCache,
211
212 Requirements(String),
214
215 UnknownVersion(String),
217
218 Execution(ExecutionError),
220}
221
222#[derive(Debug, ThisError, Display)]
223pub struct CacheError(#[from] ExecutionError);
225
226impl Error {
227 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 || matcher.starts_with("No matches found")
246 {
247 Error::NotFound
248 } else if matcher.starts_with("Error: Cache-only enabled but no cache")
249 || 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 #[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 Ok("".to_string()),
319 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 #[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 #[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 Ok("".to_string()),
374 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 #[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 Ok("".to_string()),
435 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 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 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 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}