cnf_lib/environment/
mod.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//! Environment handlers.
6//!
7//! This module contains handlers for various environments. Generally a handler is characterized by
8//! the presence of the following public functions within its submodule:
9//!
10//! - `detect` to find out if this is the current execution environment (where the command wasn't
11//!   found)
12//! - `find` to see if a certain command exists in this execution environment
13//! - `handle` to kick off the chain to look a certain command up from inside this environment
14//! - `execute` to und a command in this environment
15//!
16//! Especially the latter function isn't
17pub mod container;
18pub mod distrobox;
19pub mod host;
20pub mod toolbx;
21#[cfg(test)]
22use crate::test::prelude::*;
23use futures::TryFutureExt;
24use prelude::*;
25use tracing::Instrument;
26
27#[allow(unused_imports)]
28pub(crate) mod prelude {
29    pub use crate::{
30        environment::{self, Environment},
31        util::{cmd, CommandLine, OutputMatcher},
32    };
33    pub use async_trait::async_trait;
34    pub use serde_derive::{Deserialize, Serialize};
35    pub use std::fmt;
36    pub use thiserror::Error as ThisError;
37    pub use tokio::process::Command;
38    pub use tracing::{debug, error, info, span, trace, warn};
39}
40
41/// Trait for usable environments.
42#[async_trait]
43pub trait IsEnvironment: fmt::Debug + fmt::Display {
44    type Err;
45
46    /// Returns true if the given Env is available at all.
47    ///
48    /// We assume the environment `Host` to be available unconditionally. Other environments, such
49    /// as `toolbx`, can only be available when at least the `toolbx` executable is present, or we
50    /// are currently inside a toolbx.
51    async fn exists(&self) -> bool;
52
53    /// Execute a command within this environment
54    ///
55    /// [`IsProvider`](crate::provider::IsProvider) implementations should prefer calling
56    /// [`output_of()`] instead of interacting with an [`Environment`] instance directly.
57    /// Refer to [`output_of()`] for details.
58    ///
59    /// [`output_of()`]: Environment::output_of()
60    async fn execute(&self, command: CommandLine) -> Result<Command, Self::Err>;
61}
62
63/// All the execution environments known to the applicaiton.
64#[derive(PartialEq, Eq, Debug, PartialOrd, Ord, Serialize, Deserialize)]
65pub enum Environment {
66    //Container,
67    /// The host system
68    Host(host::Host),
69    /// A distrobox instance
70    Distrobox(distrobox::Distrobox),
71    /// A toolbx instance
72    Toolbx(toolbx::Toolbx),
73    /// Mock environment (test only)
74    #[cfg(test)]
75    Mock(Mock),
76}
77
78impl Environment {
79    /// Execute a command in this environment and collect stdout, if possible.
80    ///
81    /// Executes `cmd` inside this env, collecting all output. If execution finishes successfully,
82    /// the stdout is returned as [`String`]. Otherwise, an [`ExecutionError`] is returned instead.
83    ///
84    /// The [`ExecutionError`] type takes care of a lot of boilerplate code that is otherwise necessary
85    /// to detect specific error conditions caused by *different* environments (because they can
86    /// produce different output for similar/identical error conditions).
87    ///
88    /// Refer to [`OutputMatcher`] for added convenience when trying to recover from a
89    /// [`NonZero`](ExecutionError::NonZero) error.
90    ///
91    ///
92    /// ### Test integration
93    ///
94    /// In conjunction with the [`Mock`](crate::test::mock::Mock) type, this function allows
95    /// replaying the output of called commands. Use the [`quick_test!`](crate::test::quick_test)
96    /// macro to simulate command outputs inside tests.
97    // TODO(hartan): This swallows stderr in case of success. Is that a problem?
98    #[cfg(not(test))]
99    pub async fn output_of(&self, cmd: CommandLine) -> Result<String, ExecutionError> {
100        let main_command = cmd.command();
101        let output = self
102            .execute(cmd)
103            .await
104            .map_err(ExecutionError::Environment)?
105            // NOTE: This bit is very important. If ommitted, **all** `Command` instances spawned
106            // by the application ever will block on each other waiting to read things from stdin.
107            .stdin(std::process::Stdio::null())
108            .output()
109            .await
110            .map_err(|err| match err.kind() {
111                std::io::ErrorKind::NotFound => ExecutionError::NotFound(main_command.clone()),
112                _ => ExecutionError::Unknown(err),
113            })?;
114
115        if output.status.success() {
116            Ok(String::from_utf8_lossy(&output.stdout).to_string())
117        } else {
118            let matcher = OutputMatcher::new(&output);
119            if matcher.starts_with(
120            // When calling into toolbx from host
121                &format!(
122                    "Error: crun: executable file `{}` not found in $PATH: No such file or directory",
123                    main_command.clone()
124                )
125            ) ||
126            // When calling into host from toolbx
127            matcher.starts_with(
128                    "Portal call failed: Failed to start command: Failed to execute child process"
129                ) && matcher.ends_with("(No such file or directory)")
130            {
131                Err(ExecutionError::NotFound(main_command))
132            } else {
133                Err(ExecutionError::NonZero {
134                    command: main_command,
135                    output,
136                })
137            }
138        }
139    }
140    #[cfg(test)]
141    pub async fn output_of(&self, _command: CommandLine) -> Result<String, ExecutionError> {
142        match self {
143            Environment::Mock(ref mock) => mock.pop_raw(),
144            _ => panic!("cannot execute commands in tests with regular envs"),
145        }
146    }
147
148    pub fn to_json(&self) -> String {
149        serde_json::to_string(&self)
150            .unwrap_or_else(|_| panic!("failed to serialize env '{}' to JSON", self))
151    }
152
153    /// Start an environment manually.
154    ///
155    /// This is usually done automatically when creating environment instances through their
156    /// constructors. For some environments, this is a no-op.
157    pub fn start(&self) -> Result<(), anyhow::Error> {
158        match self {
159            Self::Distrobox(val) => Ok(val.start()?),
160            Self::Toolbx(val) => Ok(val.start()?),
161            _ => Ok(()),
162        }
163    }
164}
165
166impl fmt::Display for Environment {
167    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168        match self {
169            Self::Host(val) => write!(f, "{}", val),
170            Self::Distrobox(val) => write!(f, "{}", val),
171            Self::Toolbx(val) => write!(f, "{}", val),
172            #[cfg(test)]
173            Self::Mock(val) => write!(f, "{}", val),
174        }
175    }
176}
177
178#[async_trait]
179impl IsEnvironment for Environment {
180    type Err = Error;
181
182    async fn exists(&self) -> bool {
183        match self {
184            Self::Host(val) => val.exists(),
185            Self::Distrobox(val) => val.exists(),
186            Self::Toolbx(val) => val.exists(),
187            #[cfg(test)]
188            Self::Mock(val) => val.exists(),
189        }
190        .await
191    }
192
193    async fn execute(&self, command: CommandLine) -> Result<Command, Self::Err> {
194        async move {
195            match self {
196                Self::Host(val) => val.execute(command).map_err(Error::ExecuteOnHost).await,
197                Self::Distrobox(val) => {
198                    val.execute(command)
199                        .map_err(|e| Self::Err::ExecuteInDistrobox {
200                            distrobox: val.to_string(),
201                            source: e,
202                        })
203                        .await
204                }
205                Self::Toolbx(val) => {
206                    val.execute(command)
207                        .map_err(|e| Self::Err::ExecuteInToolbx {
208                            toolbx: val.to_string(),
209                            source: e,
210                        })
211                        .await
212                }
213                #[cfg(test)]
214                Self::Mock(val) => Ok(val.execute(command).await.unwrap()),
215            }
216        }
217        .in_current_span()
218        .await
219    }
220}
221
222impl From<host::Host> for Environment {
223    fn from(value: host::Host) -> Self {
224        Self::Host(value)
225    }
226}
227
228impl From<distrobox::Distrobox> for Environment {
229    fn from(value: distrobox::Distrobox) -> Self {
230        Self::Distrobox(value)
231    }
232}
233
234impl From<toolbx::Toolbx> for Environment {
235    fn from(value: toolbx::Toolbx) -> Self {
236        Self::Toolbx(value)
237    }
238}
239
240impl std::str::FromStr for Environment {
241    type Err = SerializationError;
242
243    fn from_str(s: &str) -> Result<Self, Self::Err> {
244        let val: Self =
245            serde_json::from_str(s).map_err(|_| SerializationError { raw: s.to_owned() })?;
246        Ok(val)
247    }
248}
249
250// TODO(hartan): The error message from this is far from ideal.
251#[derive(Debug, ThisError, Serialize, Deserialize)]
252#[error("invalid environment specification '{raw}'")]
253pub struct SerializationError {
254    raw: String,
255}
256
257/// Return the current execution environment.
258// TODO(hartan): This should change. Ideally the lib loads all envs once during startup, storing
259// them into a `Vec<Arc<Environment>>`, and then we can return a fresh `Arc<>` here. This way all
260// envs exist exactly once and can sensibly share state (e.g. system info, if that ever becomes
261// important).
262pub fn current() -> Environment {
263    if toolbx::detect() {
264        Environment::Toolbx(toolbx::Toolbx::current().unwrap())
265    } else if distrobox::detect() {
266        Environment::Distrobox(distrobox::Distrobox::current().unwrap())
267    } else {
268        Environment::Host(host::Host::new())
269    }
270}
271
272/// Preserve the users environments.
273///
274/// Attempts to replicate all environment variables of the current process in the spawned
275/// environment. Refer to the source code to see exactly which variables are preserved. Variables
276/// are returned as a vector of strings with 'KEY=VALUE' notation.
277pub fn read_env_vars() -> Vec<String> {
278    let exclude = [
279        "HOST",
280        "HOSTNAME",
281        "HOME",
282        "LANG",
283        "LC_CTYPE",
284        "PATH",
285        "PROFILEREAD",
286        "SHELL",
287    ];
288
289    std::env::vars()
290        .filter_map(|(mut key, value)| {
291            if exclude.contains(&&key[..])
292                || key.starts_with('_')
293                || (key.starts_with("XDG_") && key.ends_with("_DIRS"))
294            {
295                None
296            } else {
297                key.push('=');
298                key.push_str(&value);
299                Some(key)
300            }
301        })
302        .collect::<Vec<_>>()
303}
304
305/// Common errors from command execution.
306// TODO(hartan): This doesn't detect issues with privilege escalation yet. I should probably add a
307// different error-variant for this.
308#[derive(Debug, ThisError)]
309pub enum ExecutionError {
310    /// Requested executable cannot be found
311    #[error("command not found: {0}")]
312    NotFound(String),
313
314    /// Error inside an [`Environment`]
315    #[error(transparent)]
316    Environment(#[from] Error),
317
318    /// Error from [`Command`]
319    #[error(transparent)]
320    Unknown(#[from] std::io::Error),
321
322    /// Error from the called command
323    ///
324    /// ## Note
325    ///
326    /// When calling from the host into a toolbx container, *stdout* and *stderr* are both merged
327    /// into *stdout*. This is currently a shortcoming of the involved call to `podman exec -t
328    /// ...`. Refer to the manpage of `podman-exec(1)`, in particular the *NOTE* attached to
329    /// `--tty`.
330    ///
331    /// This means that when checking for messages in `output.stderr`, you should **always** check
332    /// for the existence of your message in **both stderr and stdout**.
333    #[error("command '{command}' exited with nonzero code")]
334    NonZero {
335        command: String,
336        output: std::process::Output,
337    },
338}
339
340#[derive(Debug, ThisError)]
341pub enum StartError {
342    #[error(transparent)]
343    Distrobox(#[from] distrobox::StartDistroboxError),
344
345    #[error(transparent)]
346    Toolbx(#[from] toolbx::StartToolbxError),
347}
348
349#[derive(Debug, ThisError)]
350pub enum Error {
351    #[error("failed to execute command on host")]
352    ExecuteOnHost(#[from] <host::Host as IsEnvironment>::Err),
353
354    #[error("failed to execute command in '{toolbx}'")]
355    ExecuteInToolbx {
356        toolbx: String,
357        source: <toolbx::Toolbx as IsEnvironment>::Err,
358    },
359
360    #[error("failed to execute command in '{distrobox}'")]
361    ExecuteInDistrobox {
362        distrobox: String,
363        source: <distrobox::Distrobox as IsEnvironment>::Err,
364    },
365}