1use 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 pub fn new() -> Self {
31 Default::default()
32 }
33
34 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 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 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 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 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 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 } 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 #[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 Ok("".to_string()),
270 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 #[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 #[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 Ok("".to_string()),
320 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 #[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 Ok("".to_string()),
380 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 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 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 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}