Skip to main content

cnf_lib/provider/
apt.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// SPDX-FileCopyrightText: (C) 2023 Andreas Hartmann <hartan@7x.de>
3// This file is part of cnf-lib, available at <https://gitlab.com/hartang/rust/cnf>
4
5//! # Search packages with APT
6//!
7//! Searches for an executable in the APT cache via `apt-file`.
8use crate::provider::prelude::*;
9use futures::StreamExt;
10use std::ops::Not;
11
12/// Provider for the `apt` package manager.
13#[derive(Default, Debug, PartialEq, Clone)]
14// In the future these may get (mutable) internal state.
15#[allow(missing_copy_implementations)]
16pub struct Apt {}
17
18impl Apt {
19    /// Create a new instance.
20    pub fn new() -> Apt {
21        Default::default()
22    }
23
24    /// Search for a given command.
25    async fn search(&self, env: &Arc<Environment>, command: &str) -> Result<String, Error> {
26        env.output_of(cmd!(
27            "apt-file",
28            "search",
29            "--regexp",
30            &format!("bin.*/{}$", command)
31        ))
32        .await
33        .map_err(Error::from)
34    }
35
36    /// Update the `apt` cache.
37    async fn update_cache(&self, env: &Arc<Environment>) -> Result<(), CacheError> {
38        env.output_of(cmd!("apt-file", "update").privileged())
39            .await
40            .map(|_| ())
41            .map_err(CacheError::from)
42    }
43
44    /// Obtain a list of candidates from [`Apt::search`] output.
45    async fn get_candidates_from_output(
46        &self,
47        output: &str,
48        env: Arc<Environment>,
49    ) -> Vec<Candidate> {
50        futures::stream::iter(output.lines())
51            .then(|line| {
52                async {
53                    let line = line.to_owned();
54                    let cloned_env = env.clone();
55                    let mut candidate = Candidate::default();
56
57                    if let Some((package, bin)) = line.split_once(": ") {
58                        candidate.package = package.to_string();
59                        candidate.actions.execute = cmd!(bin);
60
61                        // Try to get additional package information
62                        if let Ok(output) = cloned_env
63                            .output_of(cmd!("apt-cache", "show", package))
64                            .await
65                            .with_context(|| {
66                                format!("failed to gather additional info for '{}'", line)
67                            })
68                            .to_log()
69                        {
70                            for line in output.lines() {
71                                match line.split_once(": ") {
72                                    Some(("Version", version)) => {
73                                        candidate.version = version.to_string()
74                                    }
75                                    Some(("Origin", origin)) => {
76                                        candidate.origin = origin.to_string()
77                                    }
78                                    Some(("Description", text)) => {
79                                        candidate.description = text.to_string()
80                                    }
81                                    _ => continue,
82                                }
83                            }
84                        }
85
86                        // Check whether the package is installed
87                        candidate.actions.install = cloned_env
88                            .output_of(cmd!("dpkg-query", "-W", package))
89                            .await
90                            // dpkg returned success, package is installed
91                            .map(|_| true)
92                            // Package not installed
93                            .unwrap_or(false)
94                            // Invert the result: We only add install commands if the package isn't
95                            // present
96                            .not()
97                            // If the package really isn't installed
98                            .then_some(cmd!("apt", "install", "-y", package).privileged());
99                    }
100                    candidate
101                }
102                .in_current_span()
103            })
104            .collect::<Vec<Candidate>>()
105            .await
106    }
107}
108
109impl fmt::Display for Apt {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        write!(f, "APT")
112    }
113}
114
115#[async_trait]
116impl IsProvider for Apt {
117    async fn search_internal(
118        &self,
119        command: &str,
120        target_env: Arc<Environment>,
121    ) -> ProviderResult<Vec<Candidate>> {
122        let stdout = match self.search(&target_env, command).await {
123            Ok(val) => val,
124            Err(Error::NoCache) => {
125                self.update_cache(&target_env)
126                    .await
127                    .map_err(Error::Cache)
128                    .map_err(|e| e.into_provider(command))?;
129                self.search(&target_env, command)
130                    .await
131                    .map_err(|e| e.into_provider(command))?
132            }
133            Err(e) => return Err(e.into_provider(command)),
134        };
135
136        Ok(self.get_candidates_from_output(&stdout, target_env).await)
137    }
138}
139
140/// Error type for the `apt` provider.
141#[derive(Debug, ThisError, Display)]
142pub enum Error {
143    /// cache error
144    Cache(#[from] CacheError),
145
146    /// system cache is empty, please run 'apt-file update' as root first
147    NoCache,
148
149    /// requirement not fulfilled: '{0}'
150    Requirements(String),
151
152    /// command not found
153    NotFound,
154
155    /// execution terminated with nonzero exit code
156    Execution(ExecutionError),
157}
158
159impl From<ExecutionError> for Error {
160    fn from(value: ExecutionError) -> Self {
161        match value {
162            ExecutionError::NonZero { ref output, .. } => {
163                let matcher = OutputMatcher::new(output);
164                if matcher.contains("E: The cache is empty") {
165                    Error::NoCache
166                } else if matcher.is_empty() {
167                    Error::NotFound
168                } else {
169                    Error::Execution(value)
170                }
171            }
172            ExecutionError::NotFound(val) => Error::Requirements(val),
173            _ => Error::Execution(value),
174        }
175    }
176}
177
178impl Error {
179    /// Convert this [`Error`] instance into a [`ProviderError`].
180    pub fn into_provider(self, command: &str) -> ProviderError {
181        match self {
182            Self::NotFound => ProviderError::NotFound(command.to_string()),
183            Self::Requirements(what) => ProviderError::Requirements(what),
184            Self::Execution(err) => ProviderError::Execution(err),
185            _ => ProviderError::ApplicationError(anyhow::Error::new(self)),
186        }
187    }
188}
189
190#[derive(Debug, ThisError, Display)]
191/// failed to update apt-file cache
192pub struct CacheError(#[from] ExecutionError);
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::test::prelude::*;
198
199    #[test]
200    fn initialize() {
201        let _apt = Apt::new();
202    }
203
204    test::default_tests!(Apt::new());
205
206    /// Searching with empty apt-cache
207    ///
208    /// - Searched with: apt 2.4.9 (apt-file has no version output...)
209    /// - Search command: "apt-file search --regexp 'bin.*/btrbk$'"
210    #[test]
211    fn cache_empty() {
212        let query = quick_test!(
213            Apt::new(),
214            Err(ExecutionError::NonZero {
215                command: "apt-file".to_string(),
216                output: std::process::Output {
217                    stdout: r"Finding relevant cache files to search ...".into(),
218                    stderr: r#"E: The cache is empty. You need to run "apt-file update" first.
219"#
220                    .into(),
221                    status: ExitStatus::from_raw(3),
222                },
223            }),
224            Err(ExecutionError::NonZero {
225                command: "sudo".to_string(),
226                output: std::process::Output {
227                    stdout: r"".into(),
228                    stderr: r#"sudo: a password is required\n"#.into(),
229                    status: ExitStatus::from_raw(1),
230                },
231            })
232        );
233
234        assert::is_err!(query);
235        assert::err::application!(query);
236    }
237
238    /// Searching an existent package
239    ///
240    /// - Searched with: apt 2.4.9
241    /// - Search command: "apt-file search --regexp 'bin.*/btrbk$'"
242    #[test]
243    fn search_existent() {
244        let query = quick_test!(Apt::new(),
245            // Output from apt-file
246            Ok("btrbk: /usr/bin/btrbk".to_string()),
247            // Outout from apt-cache show
248            Ok("
249Package: btrbk
250Architecture: all
251Version: 0.31.3-1
252Priority: optional
253Section: universe/utils
254Origin: Ubuntu
255Maintainer: Ubuntu Developers <ubuntu-devel-discuss@lists.ubuntu.com>
256Original-Maintainer: Axel Burri <axel@tty0.ch>
257Bugs: https://bugs.launchpad.net/ubuntu/+filebug
258Installed-Size: 404
259Depends: perl, btrfs-progs (>= 4.12)
260Recommends: openssh-client, mbuffer
261Suggests: openssl, python3
262Filename: pool/universe/b/btrbk/btrbk_0.31.3-1_all.deb
263Size: 107482
264MD5sum: ad4aaa293c91981fcde34a413f043f37
265SHA1: 88734d2e6f6c5bc6597edd4da22f67bf86ae45ad
266SHA256: b554489c952390da62c0c2de6012883f18a932b1b40157254846332fb6aaa889
267SHA512: 5dcd7015b325fcc5f6acd9b70c4f2c511826aa03426b594a278386091b1f36af6fef6a05a99f5bcc0866badf96fa2342afb6a44a0249d7d67199f0d877f3614c
268Homepage: https://digint.ch/btrbk/
269Description: backup tool for btrfs subvolumes
270Description-md5: 13434d9f502ec934b9db33ec622b0769
271".to_string()),
272            // Output from dpkg-query
273            Err(ExecutionError::NonZero {
274                command: "dpkg-query".to_string(),
275                output: std::process::Output {
276                    stdout: r"".into(),
277                    stderr: r"dpkg-query: no packages found matching btrbk\n".into(),
278                    status: ExitStatus::from_raw(1),
279                }
280            })
281        );
282
283        let result = query.results.unwrap();
284        assert_eq!(result.len(), 1);
285        assert_eq!(result[0].package, "btrbk".to_string());
286        assert_eq!(result[0].actions.execute, vec!["/usr/bin/btrbk"].into());
287        assert_eq!(result[0].version, "0.31.3-1".to_string());
288        assert_eq!(result[0].origin, "Ubuntu".to_string());
289        assert_eq!(
290            result[0].description,
291            "backup tool for btrfs subvolumes".to_string()
292        );
293        assert!(result[0].actions.install.is_some());
294    }
295
296    /// Searching a non-existent package
297    ///
298    /// - Searched with: apt 2.4.9
299    /// - Search command: "apt-file search --regexp 'bin.*/asdwasda$'"
300    #[test]
301    fn search_nonexistent() {
302        let query = quick_test!(
303            Apt::new(),
304            Err(ExecutionError::NonZero {
305                command: "apt-file".to_string(),
306                output: std::process::Output {
307                    stdout: r"".into(),
308                    stderr: r"".into(),
309                    status: ExitStatus::from_raw(1),
310                }
311            })
312        );
313
314        assert::is_err!(query);
315        assert::err::not_found!(query);
316    }
317}