use crate::provider::prelude::*;
use futures::StreamExt;
#[derive(Default, Debug, PartialEq)]
#[allow(missing_copy_implementations)]
pub struct Dnf;
#[derive(Debug, PartialEq)]
enum DnfVersion {
DNF4,
DNF5,
}
impl fmt::Display for Dnf {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "DNF")
}
}
impl Dnf {
pub fn new() -> Self {
Default::default()
}
async fn get_version(&self, target_env: &Arc<Environment>) -> Result<DnfVersion, Error> {
let cmd = cmd!("dnf", "--version");
let output = target_env.output_of(cmd).await.map_err(Error::from)?;
let first_line = output.lines().next();
if first_line.is_some_and(|s| s.starts_with("dnf5")) {
Ok(DnfVersion::DNF5)
} else if first_line.is_some_and(|s| s.starts_with("4.")) {
Ok(DnfVersion::DNF4)
} else {
Err(Error::UnknownVersion(output))
}
}
fn get_candidates_from_provides_output(&self, output: String) -> Vec<Candidate> {
let lines = output
.lines()
.map(|s| s.to_string())
.collect::<Vec<String>>();
let mut results = vec![];
let mut found_empty = true;
let mut candidate = Candidate::default();
for line in lines {
if line.is_empty() {
found_empty = true;
continue;
}
let (before, after) = match line.split_once(" : ") {
Some((a, b)) => (a.trim(), b.trim()),
None => {
warn!("ignoring unexpected output from dnf: '{}'", line);
continue;
}
};
if found_empty {
if !candidate.package.is_empty() {
results.push(candidate);
}
candidate = Candidate::default();
candidate.package = before.to_string();
candidate.description = after.to_string();
candidate.actions.install = Some(cmd!("dnf", "install", "-y", before).privileged());
found_empty = false;
}
if before == "Repo" {
candidate.origin = after.to_string();
}
if before == "Provide" {
if let Some((package, version)) = after.split_once(" = ") {
candidate.actions.execute = cmd!(package);
candidate.version = version.to_string();
} else {
candidate.actions.execute = cmd!(after);
}
}
}
results.push(candidate);
results
}
async fn check_installed(
&self,
target_env: &Arc<Environment>,
mut candidates: Vec<Candidate>,
) -> Vec<Candidate> {
futures::stream::iter(candidates.iter_mut())
.for_each_concurrent(None, |candidate| {
async {
let is_installed = target_env
.output_of(cmd!("rpm", "-q", "--quiet", &candidate.package))
.await
.map(|_| true)
.unwrap_or(false);
if is_installed {
candidate.actions.install = None;
}
}
.in_current_span()
})
.await;
candidates
}
async fn search(
&self,
target_env: &Arc<Environment>,
command: &str,
user_only: bool,
) -> Result<String, Error> {
let mut cmd = if self.get_version(target_env).await? == DnfVersion::DNF4 {
cmd!("dnf", "-q", "--color", "never", "provides", command)
} else {
cmd!("dnf", "-q", "provides", command)
};
if !user_only {
cmd.append(&["-C"]);
}
target_env.output_of(cmd).await.map_err(Error::from)
}
async fn update_cache(&self, target_env: &Arc<Environment>) -> Result<(), CacheError> {
target_env
.output_of(cmd!("dnf", "makecache", "-q", "--color", "never").privileged())
.await
.map(|_| ())
.map_err(CacheError::from)
}
}
#[async_trait]
impl IsProvider for Dnf {
async fn search_internal(
&self,
command: &str,
target_env: Arc<Environment>,
) -> ProviderResult<Vec<Candidate>> {
let stdout = match self.search(&target_env, command, false).await {
Ok(val) => val,
Err(Error::NoCache) => {
info!("dnf cache is outdated, trying to update");
let success = self.update_cache(&target_env).await.is_ok();
self.search(&target_env, command, !success).await
}
.map_err(|err| err.into_provider(command))?,
Err(err) => return Err(err.into_provider(command)),
};
let candidates = self.get_candidates_from_provides_output(stdout);
let mut candidates = self.check_installed(&target_env, candidates).await;
candidates.iter_mut().for_each(|candidate| {
if candidate.actions.execute.is_empty() {
candidate.actions.execute = cmd!(command.to_string());
}
});
Ok(candidates)
}
}
#[derive(Debug, ThisError, Display)]
pub enum Error {
NotFound,
Cache(#[from] CacheError),
NoCache,
Requirements(String),
UnknownVersion(String),
Execution(ExecutionError),
}
#[derive(Debug, ThisError, Display)]
pub struct CacheError(#[from] ExecutionError);
impl Error {
pub fn into_provider(self, command: &str) -> ProviderError {
match self {
Self::NotFound => ProviderError::NotFound(command.to_string()),
Self::Requirements(what) => ProviderError::Requirements(what),
_ => ProviderError::ApplicationError(anyhow::Error::new(self)),
}
}
}
impl From<ExecutionError> for Error {
fn from(value: ExecutionError) -> Self {
match value {
ExecutionError::NonZero { ref output, .. } => {
let matcher = OutputMatcher::from(output);
if matcher.starts_with("Error: No matches found")
|| matcher.starts_with("No matches found")
{
Error::NotFound
} else if matcher.starts_with("Error: Cache-only enabled but no cache")
|| matcher.starts_with("Cache-only enabled but no cache")
{
Error::NoCache
} else {
Error::Execution(value)
}
}
ExecutionError::NotFound(cmd) => Error::Requirements(cmd),
_ => Error::Execution(value),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test::prelude::*;
#[test]
fn initialize() {
let _dnf = Dnf::new();
}
fn dnf4_version_output() -> String {
r#"4.14.0
Installed: dnf-0:4.21.1-1.fc39.noarch at Tue Nov 26 07:51:32 2024
Built : Fedora Project at Sat Aug 17 03:55:02 2024
Installed: rpm-0:4.19.1.1-1.fc39.x86_64 at Tue Nov 26 07:51:32 2024
Built : Fedora Project at Wed Feb 7 16:05:57 2024"#
.to_string()
}
test::default_tests!(Dnf::new());
#[test]
fn cache_empty() {
let query = quick_test!(
Dnf::new(),
Ok(dnf4_version_output()),
Err(ExecutionError::NonZero {
command: "dnf".to_string(),
output: std::process::Output {
stdout: r"".into(),
stderr: r#"Error: Cache-only enabled but no cache for 'fedora'"#.into(),
status: ExitStatus::from_raw(1),
},
}),
Err(ExecutionError::NotFound("dnf".to_string())),
Ok(dnf4_version_output()),
Ok("
htop-3.2.1-2.fc37.x86_64 : Interactive process viewer
Repo : fedora
Matched from:
Provide : htop = 3.2.1-2.fc37
htop-3.2.2-2.fc37.x86_64 : Interactive process viewer
Repo : updates
Matched from:
Provide : htop = 3.2.2-2.fc37
"
.to_string()),
Ok("".to_string()),
Ok("".to_string())
);
let result = query.results.expect("expected successful results");
assert_eq!(result.len(), 2);
assert!(result[0].package.starts_with("htop-3.2.1-2"));
}
#[test]
fn search_nonexistent() {
let query = quick_test!(
Dnf::new(),
Ok(dnf4_version_output()),
Err(ExecutionError::NonZero {
command: "dnf".to_string(),
output: std::process::Output {
stdout: r"".into(),
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(),
status: ExitStatus::from_raw(1),
},
})
);
assert::is_err!(query);
assert::err::not_found!(query);
}
#[test]
fn matches_htop() {
let query = quick_test!(
Dnf::new(),
Ok(dnf4_version_output()),
Ok("
htop-3.2.1-2.fc37.x86_64 : Interactive process viewer
Repo : fedora
Matched from:
Provide : htop = 3.2.1-2.fc37
htop-3.2.2-2.fc37.x86_64 : Interactive process viewer
Repo : updates
Matched from:
Provide : htop = 3.2.2-2.fc37
"
.to_string()),
Ok("".to_string()),
Err(ExecutionError::NonZero {
command: "rpm".to_string(),
output: std::process::Output {
stdout: r"".into(),
stderr: r"".into(),
status: ExitStatus::from_raw(1),
},
})
);
let result = query.results.unwrap();
assert_eq!(result.len(), 2);
assert!(result[0].package.starts_with("htop-3.2.1-2"));
assert_eq!(result[0].version, "3.2.1-2.fc37");
assert_eq!(result[0].origin, "fedora");
assert_eq!(result[0].description, "Interactive process viewer");
assert_eq!(result[0].actions.execute, vec!["htop"].into());
assert_eq!(result[1].description, "Interactive process viewer");
let num_installable = result
.iter()
.fold(0, |acc, c| acc + (c.actions.install.is_some() as usize));
assert_eq!(num_installable, 1);
}
#[test]
fn matches_ping() {
let query = quick_test!(
Dnf::new(),
Ok(dnf4_version_output()),
Ok("
iputils-20211215-3.fc37.x86_64 : Network monitoring tools including ping
Repo : fedora
Matched from:
Filename : /usr/bin/ping
Provide : /bin/ping
Filename : /usr/sbin/ping
iputils-20221126-1.fc37.x86_64 : Network monitoring tools including ping
Repo : @System
Matched from:
Filename : /usr/bin/ping
Provide : /bin/ping
Filename : /usr/sbin/ping
iputils-20221126-1.fc37.x86_64 : Network monitoring tools including ping
Repo : updates
Matched from:
Filename : /usr/bin/ping
Provide : /bin/ping
Filename : /usr/sbin/ping
"
.to_string()),
Ok("".to_string()),
Err(ExecutionError::NonZero {
command: "rpm".to_string(),
output: std::process::Output {
stdout: r"".into(),
stderr: r"".into(),
status: ExitStatus::from_raw(1),
},
}),
Err(ExecutionError::NotFound("rpm".to_string()))
);
let result = query.results.unwrap();
assert!(result.len() == 3);
assert!(result[0].package.starts_with("iputils"));
assert!(result[0].version.is_empty());
assert!(result[0].origin == "fedora");
assert!(result[1].origin == "@System");
assert!(result[0].description == "Network monitoring tools including ping");
assert!(result[0].actions.execute == vec!["/bin/ping"].into());
assert!(result[1].description == "Network monitoring tools including ping");
let num_installable = result
.iter()
.fold(0, |acc, c| acc + (c.actions.install.is_some() as usize));
assert_eq!(num_installable, 2);
}
#[test]
fn matches_nmap() {
let query = quick_test!(
Dnf::new(),
Ok(dnf4_version_output()),
Ok("
nmap-3:7.93-2.fc38.x86_64 : Network exploration tool and security scanner
Repo : fedora
Matched from:
Provide : nmap = 3:7.93-2.fc38
"
.to_string()),
Err(ExecutionError::NonZero {
command: "rpm".to_string(),
output: std::process::Output {
stdout: r"".into(),
stderr: r"".into(),
status: ExitStatus::from_raw(1),
},
})
);
let result = query.results.unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].package.starts_with("nmap"));
assert_eq!(result[0].version, "3:7.93-2.fc38");
assert_eq!(result[0].origin, "fedora");
assert_eq!(
result[0].description,
"Network exploration tool and security scanner"
);
assert_eq!(result[0].actions.execute, vec!["nmap"].into());
}
}