cnf_lib/environment/
toolbx.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//! # Toolbx Environment Handler.
6//!
7//! This module handles command-not-found errors that occur while executing inside a Toolbx
8//! container. Currently, this means that commands are forwarded to the host using `flatpak-spawn`.
9//! If `flatpak-spawn` isn't present, an error is thrown instead.
10use super::prelude::*;
11
12use users::{get_current_gid, get_current_uid};
13
14use std::{io::IsTerminal, path::Path};
15
16const TOOLBX_ENV: &str = "/run/.toolboxenv";
17const CONTAINER_ENV: &str = "/run/.containerenv";
18const OS_RELEASE: &str = "/etc/os-release";
19
20#[derive(PartialEq, Eq, Debug, PartialOrd, Ord, Serialize, Deserialize)]
21pub struct Toolbx {
22    name: String,
23}
24
25impl fmt::Display for Toolbx {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        write!(f, "toolbx '{}'", self.name)
28    }
29}
30
31impl Toolbx {
32    /// Spawn a Toolbx container with a given name.
33    ///
34    /// Checks if the toolbx container exists and starts it, if necessary. If `None` is given as
35    /// name, will try to determine the default toolbx name and start that instead. Returns an
36    /// error if unsuccessful.
37    pub fn new(name: Option<String>) -> Result<Toolbx, NewToolbxError> {
38        let name = match name {
39            Some(name) if !name.is_empty() => name,
40            _ => match Toolbx::default_name() {
41                Ok(toolbx_name) => toolbx_name,
42                Err(e) => return Err(NewToolbxError::UnknownDefault(e)),
43            },
44        };
45
46        // Do an optimistic start:
47        // - If the container exists and isn't started, it will be started
48        // - If the container exists and is started, nothing happens
49        // - If the container doesn't exist, we get an error and report that
50        let ret = Self { name: name.clone() };
51        ret.start()
52            .map_err(|e| NewToolbxError::CannotStart { source: e, name })?;
53        Ok(ret)
54    }
55
56    /// Starts a given toolbx container.
57    ///
58    /// This function is automatically called by `new()` above and should only ever be called when
59    /// creating a `Toolbx` object without using the constructor. This is currently the case when
60    /// executing aliases in `cnf`, as the `Toolbx` instance is deserialized from the config in
61    /// that case.
62    pub fn start(&self) -> Result<(), StartToolbxError> {
63        let output = std::process::Command::new("podman")
64            .args(["start", &self.name])
65            .output()
66            .map_err(|e| match e.kind() {
67                std::io::ErrorKind::NotFound => StartToolbxError::NeedPodman,
68                _ => StartToolbxError::IoError(e),
69            })?;
70        if output.status.success() {
71            // All good
72            Ok(())
73        } else {
74            let matcher = OutputMatcher::new(&output);
75            if matcher.starts_with("Error: no container with name or ID")
76                && matcher.contains("found: no such container")
77            {
78                Err(StartToolbxError::NonExistent(self.name.clone()))
79            } else {
80                Err(StartToolbxError::Podman(output))
81            }
82        }
83    }
84
85    /// Get the Toolbx container currently executing CNF.
86    ///
87    /// Will return an error if the current execution environment isn't Toolbx.
88    pub fn current() -> Result<Toolbx, CurrentToolbxError> {
89        if !detect() {
90            return Err(CurrentToolbxError::NotAToolbx);
91        }
92
93        let content = std::fs::read_to_string(CONTAINER_ENV).map_err(|e| {
94            CurrentToolbxError::Environment {
95                env_file: CONTAINER_ENV.to_string(),
96                source: e,
97            }
98        })?;
99        let name = content
100            .lines()
101            .find(|line| line.contains("name=\""))
102            .ok_or_else(|| CurrentToolbxError::Name(CONTAINER_ENV.to_string()))?
103            .trim_start_matches("name=\"")
104            .trim_end_matches('"');
105
106        Ok(Toolbx {
107            name: name.to_string(),
108        })
109    }
110
111    /// Get the name of the default toolbx to lookup/execute commands in.
112    ///
113    /// The default toolbx container name is assembled from the contents of `/etc/os-release`.
114    pub fn default_name() -> Result<String, DefaultToolbxError> {
115        // Construct default toolbox name by hand. Format is $ID-toolbox-$VERSION_ID, with ID
116        // and VERSION_ID taken from /etc/os-release. See here:
117        // https://containertoolbx.org/distros/
118        debug!("Determining default toolbx name via {}", OS_RELEASE);
119
120        let content =
121            std::fs::read_to_string(OS_RELEASE).map_err(|e| DefaultToolbxError::UnknownOs {
122                file: OS_RELEASE.to_string(),
123                source: e,
124            })?;
125        let id = content
126            .lines()
127            .find(|line| line.starts_with("ID="))
128            .map(|line| line.trim_start_matches("ID=").trim_matches('"'))
129            .ok_or(DefaultToolbxError::Id)?;
130        let version_id = content
131            .lines()
132            .find(|line| line.starts_with("VERSION_ID="))
133            .map(|line| line.trim_start_matches("VERSION_ID=").trim_matches('"'))
134            .ok_or(DefaultToolbxError::VersionId)?;
135
136        Ok(format!("{}-toolbox-{}", id, version_id))
137    }
138}
139
140#[async_trait]
141impl environment::IsEnvironment for Toolbx {
142    type Err = Error;
143
144    async fn exists(&self) -> bool {
145        if detect() {
146            true
147        } else if let Environment::Host(host) = environment::current() {
148            // The result in this case is indeed `Infallible`, but switching an `if-let` for an
149            // `unwrap` is outright stupid IMO.
150            #[allow(irrefutable_let_patterns)]
151            if let Ok(mut cmd) = host
152                .execute(crate::environment::cmd!("toolbox", "--version"))
153                .await
154            {
155                cmd.stdout(std::process::Stdio::null())
156                    .stderr(std::process::Stdio::null())
157                    .status()
158                    .await
159                    .map(|status| status.success())
160                    .unwrap_or(false)
161            } else {
162                false
163            }
164        } else {
165            false
166        }
167    }
168
169    async fn execute(&self, command: CommandLine) -> Result<Command, Self::Err> {
170        debug!("preparing execution: {}", command);
171        let mut cmd: Command;
172
173        match environment::current() {
174            Environment::Distrobox(_) => {
175                return Err(Error::Unimplemented(
176                    "running in a toolbx from a distrobox".to_string(),
177                ));
178            }
179            Environment::Toolbx(t) => {
180                if self == &t {
181                    // This is the toolbx container we are currently running in
182                    // We expect toolbx containers to *always* run a unix OS, or at least something
183                    // that has `sudo`.
184                    if command.get_privileged() {
185                        cmd = Command::new("sudo");
186                        if !command.get_interactive() {
187                            cmd.arg("-n");
188                        }
189
190                        cmd.arg(command.command());
191                    } else {
192                        cmd = Command::new(command.command());
193                    }
194
195                    cmd.args(command.args());
196                } else {
197                    return Err(Error::Unimplemented(
198                        "running in a toolbx from another toolbx".to_string(),
199                    ));
200                }
201            }
202            Environment::Host(_) => {
203                cmd = Command::new("podman");
204
205                cmd.args(["exec", "-i"]);
206                // The toolbx container by default isn't launched with the `--user` option, we must
207                // take care of this ourselves.
208                cmd.arg("--user");
209                cmd.arg(format!("{}:{}", get_current_uid(), get_current_gid()));
210                // Fix the working directory
211                cmd.arg("--workdir");
212                cmd.arg(std::env::current_dir().map_err(Error::UnknownCwd)?);
213                // Keep some env vars
214                for var in environment::read_env_vars() {
215                    cmd.args(["-e", &var]);
216                }
217
218                // Avoid accidental detach from container
219                cmd.args(["--detach-keys", ""]);
220
221                // Only attach to the tty if we really have a tty, too
222                // FIXME: Is this really the correct way to check?
223                if std::io::stdout().is_terminal() && std::io::stdin().is_terminal() {
224                    cmd.arg("-t");
225                }
226
227                // Can't run command in toolbx if we don't have one
228                cmd.arg(&self.name);
229
230                // This is the real command we're looking for (with arguments)
231                if command.get_privileged() {
232                    cmd.args(["sudo", "-S", "-E"]);
233                    // NOTE: We ignore `get_interactive` here. because toolbox seems to do weird
234                    // things regarding sudo. When adding the `-n` flag to request non-interactive
235                    // auth, sudo will fail, requiring a pssword. However, factually running `sudo`
236                    // in a toolbx container *does not* require a password under normal
237                    // circumstances. Just ignoring interactivity here solves this issue (but don't
238                    // ask me why).
239                }
240
241                cmd.arg(command.command()).args(command.args());
242            }
243            #[cfg(test)]
244            Environment::Mock(_) => unimplemented!(),
245        }
246
247        trace!("full command: {:?}", cmd);
248        Ok(cmd)
249    }
250}
251
252/// Detect if the current execution environment is a Toolbx container.
253///
254/// Checks for the presence of the `.toolboxenv` files.
255pub fn detect() -> bool {
256    Path::new(TOOLBX_ENV).exists()
257}
258
259#[derive(Debug, ThisError)]
260pub enum StartToolbxError {
261    #[error("working with toolbx containers requires the 'podman' executable")]
262    NeedPodman,
263
264    #[error("podman exited with non-zero code: {0:#?}")]
265    Podman(std::process::Output),
266
267    #[error("no toolbx with name {0} exists")]
268    NonExistent(String),
269
270    #[error("unknown I/O error occured")]
271    IoError(#[from] std::io::Error),
272}
273
274#[derive(Debug, ThisError)]
275pub enum NewToolbxError {
276    #[error("failed to determine default toolbx name")]
277    UnknownDefault(#[from] DefaultToolbxError),
278
279    #[error("failed to start toolbx container with name '{name}'")]
280    CannotStart {
281        source: StartToolbxError,
282        name: String,
283    },
284}
285
286#[derive(Debug, ThisError)]
287pub enum CurrentToolbxError {
288    #[error("cannot read toolbx info from environment file '{env_file}'")]
289    Environment {
290        env_file: String,
291        source: std::io::Error,
292    },
293
294    #[error("program currently isn't run from a toolbx")]
295    NotAToolbx,
296
297    #[error("failed to read toolbx name from environment file '{0}'")]
298    Name(String),
299}
300
301#[derive(Debug, ThisError)]
302pub enum DefaultToolbxError {
303    #[error("failed to read OS information from '{file}'")]
304    UnknownOs {
305        file: String,
306        source: std::io::Error,
307    },
308
309    #[error("cannot determine OS ID from os-release info")]
310    Id,
311
312    #[error("cannot determine OS VERSION_ID from os-release info")]
313    VersionId,
314}
315
316#[derive(Debug, ThisError)]
317pub enum Error {
318    #[error("cannot determine current working directory")]
319    UnknownCwd(#[from] std::io::Error),
320
321    #[error("not implemented: {0}")]
322    Unimplemented(String),
323}