cnf_lib/provider/
apt.rs

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