Skip to main content

bootc_lib/
utils.rs

1use std::future::Future;
2use std::io::{Read, Write};
3use std::os::fd::BorrowedFd;
4use std::path::{Component, Path, PathBuf};
5use std::process::Command;
6use std::time::Duration;
7
8use anyhow::{Context, Result};
9use bootc_utils::CommandRunExt;
10use camino::Utf8Path;
11use cap_std_ext::cap_std::fs::Dir;
12use cap_std_ext::dirext::CapStdExtDirExt;
13use cap_std_ext::prelude::CapStdExtCommandExt;
14use fn_error_context::context;
15use indicatif::HumanDuration;
16use libsystemd::logging::journal_print;
17use ostree::glib;
18use ostree_ext::container::SignatureSource;
19use ostree_ext::ostree;
20
21/// Try to look for keys injected by e.g. rpm-ostree requesting machine-local
22/// changes; if any are present, return `true`.
23pub(crate) fn origin_has_rpmostree_stuff(kf: &glib::KeyFile) -> bool {
24    // These are groups set in https://github.com/coreos/rpm-ostree/blob/27f72dce4f9b5c176ad030911c12354e2498c07d/rust/src/origin.rs#L23
25    // TODO: Add some notion of "owner" into origin files
26    for group in ["rpmostree", "packages", "overrides", "modules"] {
27        if kf.has_group(group) {
28            return true;
29        }
30    }
31    false
32}
33
34// Access the file descriptor for a sysroot
35#[allow(unsafe_code)]
36pub(crate) fn sysroot_fd(sysroot: &ostree::Sysroot) -> BorrowedFd<'_> {
37    unsafe { BorrowedFd::borrow_raw(sysroot.fd()) }
38}
39
40// Return a cap-std `Dir` type for a sysroot
41pub(crate) fn sysroot_dir(sysroot: &ostree::Sysroot) -> Result<Dir> {
42    Dir::reopen_dir(&sysroot_fd(sysroot)).map_err(Into::into)
43}
44
45// Return a cap-std `Dir` type for a deployment.
46// TODO: in the future this should perhaps actually mount via composefs
47pub(crate) fn deployment_fd(
48    sysroot: &ostree::Sysroot,
49    deployment: &ostree::Deployment,
50) -> Result<Dir> {
51    let sysroot_dir = &Dir::reopen_dir(&sysroot_fd(sysroot))?;
52    let dirpath = sysroot.deployment_dirpath(deployment);
53    sysroot_dir.open_dir(&dirpath).map_err(Into::into)
54}
55
56/// Given an mount option string list like foo,bar=baz,something=else,ro parse it and find
57/// the first entry like $optname=
58/// This will not match a bare `optname` without an equals.
59pub(crate) fn find_mount_option<'a>(
60    option_string_list: &'a str,
61    optname: &'_ str,
62) -> Option<&'a str> {
63    option_string_list
64        .split(',')
65        .filter_map(|k| k.split_once('='))
66        .filter_map(|(k, v)| (k == optname).then_some(v))
67        .next()
68}
69
70pub fn have_executable(name: &str) -> Result<bool> {
71    for elt in executable_candidates(name) {
72        if elt.try_exists()? {
73            return Ok(true);
74        }
75    }
76    Ok(false)
77}
78
79fn executable_candidates(name: &str) -> Vec<PathBuf> {
80    let Some(path) = std::env::var_os("PATH") else {
81        return Vec::new();
82    };
83    std::env::split_paths(&path)
84        .map(|mut path| {
85            path.push(name);
86            path
87        })
88        .collect()
89}
90
91/// Like [`have_executable`], but checks inside a filesystem root (e.g. a
92/// container image) rather than the host's `$PATH`.
93pub fn have_executable_in_root(root: &Dir, name: &str) -> Result<bool> {
94    for candidate in executable_candidates(name) {
95        let candidate = candidate.strip_prefix("/").unwrap_or(&candidate);
96        if root
97            .try_exists(candidate)
98            .with_context(|| format!("checking whether {} exists", candidate.display()))?
99        {
100            return Ok(true);
101        }
102    }
103    Ok(false)
104}
105
106/// Given a target directory, if it's a read-only mount, then remount it writable
107#[context("Opening {target} with writable mount")]
108pub(crate) fn open_dir_remount_rw(root: &Dir, target: &Utf8Path) -> Result<Dir> {
109    if matches!(root.is_mountpoint(target), Ok(Some(true))) {
110        tracing::debug!("Target {target} is a mountpoint, remounting rw");
111        let st = Command::new("mount")
112            .args(["-o", "remount,rw", target.as_str()])
113            .cwd_dir(root.try_clone()?)
114            .status()?;
115
116        anyhow::ensure!(st.success(), "Failed to remount: {st:?}");
117    }
118    root.open_dir(target).map_err(anyhow::Error::new)
119}
120
121/// Given a target path, remove its immutability if present
122#[context("Removing immutable flag from {target}")]
123pub(crate) fn remove_immutability(root: &Dir, target: &Utf8Path) -> Result<()> {
124    use anyhow::ensure;
125
126    tracing::debug!("Target {target} is a mountpoint, remounting rw");
127    let st = Command::new("chattr")
128        .args(["-i", target.as_str()])
129        .cwd_dir(root.try_clone()?)
130        .status()?;
131
132    ensure!(st.success(), "Failed to remove immutability: {st:?}");
133
134    Ok(())
135}
136
137pub(crate) fn spawn_editor(tmpf: &tempfile::NamedTempFile) -> Result<()> {
138    let editor_variables = ["EDITOR"];
139    // These roughly match https://github.com/systemd/systemd/blob/769ca9ab557b19ee9fb5c5106995506cace4c68f/src/shared/edit-util.c#L275
140    let backup_editors = ["nano", "vim", "vi"];
141    let editor = editor_variables.into_iter().find_map(std::env::var_os);
142    let editor = if let Some(e) = editor.as_ref() {
143        e.to_str()
144    } else {
145        backup_editors
146            .into_iter()
147            .find(|v| std::path::Path::new("/usr/bin").join(v).exists())
148    };
149    let editor =
150        editor.ok_or_else(|| anyhow::anyhow!("$EDITOR is unset, and no backup editor found"))?;
151    let mut editor_args = editor.split_ascii_whitespace();
152    let argv0 = editor_args
153        .next()
154        .ok_or_else(|| anyhow::anyhow!("Invalid editor: {editor}"))?;
155    Command::new(argv0)
156        .args(editor_args)
157        .arg(tmpf.path())
158        .lifecycle_bind()
159        .run_inherited()
160        .with_context(|| format!("Invoking editor {editor} failed"))
161}
162
163/// Convert a combination of values (likely from CLI parsing) into a signature source
164pub(crate) fn sigpolicy_from_opt(enforce_container_verification: bool) -> SignatureSource {
165    match enforce_container_verification {
166        true => SignatureSource::ContainerPolicy,
167        false => SignatureSource::ContainerPolicyAllowInsecure,
168    }
169}
170
171/// Output a warning message that we want to be quite visible.
172/// The process (thread) execution will be delayed for a short time.
173pub(crate) fn medium_visibility_warning(s: &str) {
174    anstream::eprintln!(
175        "{}{s}{}",
176        anstyle::AnsiColor::Red.render_fg(),
177        anstyle::Reset.render()
178    );
179    // When warning, add a sleep to ensure it's seen
180    std::thread::sleep(std::time::Duration::from_secs(1));
181}
182
183/// Call an async task function, and write a message to stderr
184/// with an automatic spinner to show that we're not blocked.
185/// Note that generally the called function should not output
186/// anything to stderr as this will interfere with the spinner.
187pub(crate) async fn async_task_with_spinner<F, T>(msg: &str, f: F) -> T
188where
189    F: Future<Output = T>,
190{
191    let start_time = std::time::Instant::now();
192    let pb = indicatif::ProgressBar::new_spinner();
193    let style = indicatif::ProgressStyle::default_bar();
194    pb.set_style(style.template("{spinner} {msg}").unwrap());
195    pb.set_message(msg.to_string());
196    pb.enable_steady_tick(Duration::from_millis(150));
197    // We need to handle the case where we aren't connected to
198    // a tty, so indicatif would show nothing by default.
199    if pb.is_hidden() {
200        eprint!("{msg}...");
201        std::io::stderr().flush().unwrap();
202    }
203    let r = f.await;
204    let elapsed = HumanDuration(start_time.elapsed());
205    let _ = journal_print(
206        libsystemd::logging::Priority::Info,
207        &format!("completed task in {elapsed}: {msg}"),
208    );
209    if pb.is_hidden() {
210        eprintln!("done ({elapsed})");
211    } else {
212        pb.finish_with_message(format!("{msg}: done ({elapsed})"));
213    }
214    r
215}
216
217/// Given a possibly tagged image like quay.io/foo/bar:latest and a digest 0ab32..., return
218/// the digested form quay.io/foo/bar:latest@sha256:0ab32...
219/// If the image already has a digest, it will be replaced.
220#[allow(dead_code)]
221pub(crate) fn digested_pullspec(image: &str, digest: &str) -> String {
222    let image = image.rsplit_once('@').map(|v| v.0).unwrap_or(image);
223    format!("{image}@{digest}")
224}
225
226#[derive(Debug)]
227pub enum EfiError {
228    SystemNotUEFI,
229    MissingVar,
230    #[allow(dead_code)]
231    InvalidData(&'static str),
232    #[allow(dead_code)]
233    Io(std::io::Error),
234}
235
236impl From<std::io::Error> for EfiError {
237    fn from(e: std::io::Error) -> Self {
238        EfiError::Io(e)
239    }
240}
241
242pub fn read_uefi_var(var_name: &str) -> Result<String, EfiError> {
243    use crate::install::EFIVARFS;
244    use cap_std_ext::cap_std::ambient_authority;
245
246    let efivarfs = match Dir::open_ambient_dir(EFIVARFS, ambient_authority()) {
247        Ok(dir) => dir,
248        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(EfiError::SystemNotUEFI),
249        Err(e) => Err(e)?,
250    };
251
252    match efivarfs.open(var_name) {
253        Ok(f) => parse_efi_var(f),
254        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(EfiError::MissingVar),
255        Err(e) => Err(e)?,
256    }
257}
258
259/// Parse the raw contents of an efivarfs EFI variable into its string value.
260///
261/// Ref: <https://www.kernel.org/doc/html/latest/filesystems/efivarfs.html>
262///
263/// When a content of an UEFI variable in /sys/firmware/efi/efivars is displayed,
264/// for example using “hexdump”, pay attention that the first 4 bytes of the output
265/// represent the UEFI variable attributes, in little-endian format.
266///
267/// Practically the output of each efivar is composed of:
268///
269/// 4_bytes_of_attributes + efivar_data
270fn parse_efi_var(mut reader: impl Read) -> Result<String, EfiError> {
271    let mut bytes = Vec::new();
272    reader.read_to_end(&mut bytes)?;
273
274    let Some((_, data)) = bytes.split_at_checked(4) else {
275        return Err(EfiError::InvalidData("EFI variable is too short"));
276    };
277
278    if data.len() % 2 != 0 {
279        return Err(EfiError::InvalidData(
280            "EFI var length is not valid UTF-16 LE",
281        ));
282    }
283
284    // EFI vars are UTF-16 LE
285    let data_u16_bytes: Vec<u16> = data
286        .chunks_exact(2)
287        .map(|x| u16::from_le_bytes([x[0], x[1]]))
288        .collect();
289
290    let var_string = String::from_utf16(&data_u16_bytes)
291        .map_err(|_| EfiError::InvalidData("EFI var is not UTF-16"))?;
292
293    // EFI string variables are NUL-terminated; strip the trailing
294    // NUL(s) and any surrounding whitespace so the value compares
295    // cleanly against UTF-8 strings
296    Ok(var_string.trim_matches(|c| c == '\0').trim().to_string())
297}
298
299/// Computes a relative path from `from` to `to`.
300///
301/// Both `from` and `to` must be absolute paths.
302pub(crate) fn path_relative_to(from: &Path, to: &Path) -> Result<PathBuf> {
303    if !from.is_absolute() || !to.is_absolute() {
304        anyhow::bail!("Paths must be absolute");
305    }
306
307    let from = from.components().collect::<Vec<_>>();
308    let to = to.components().collect::<Vec<_>>();
309
310    let common = from.iter().zip(&to).take_while(|(a, b)| a == b).count();
311
312    let up = std::iter::repeat(Component::ParentDir).take(from.len() - common);
313
314    let mut final_path = PathBuf::new();
315    final_path.extend(up);
316    final_path.extend(&to[common..]);
317
318    return Ok(final_path);
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn test_digested_pullspec() {
327        let digest = "ebe3bdccc041864e5a485f1e755e242535c3b83d110c0357fe57f110b73b143e";
328        assert_eq!(
329            digested_pullspec("quay.io/example/foo:bar", digest),
330            format!("quay.io/example/foo:bar@{digest}")
331        );
332        assert_eq!(
333            digested_pullspec("quay.io/example/foo@sha256:otherdigest", digest),
334            format!("quay.io/example/foo@{digest}")
335        );
336        assert_eq!(
337            digested_pullspec("quay.io/example/foo", digest),
338            format!("quay.io/example/foo@{digest}")
339        );
340    }
341
342    /// Build raw efivarfs contents: 4 attribute bytes followed by the
343    /// UTF-16 LE encoding of `s`.
344    fn efi_var_bytes(s: &str) -> Vec<u8> {
345        let mut bytes = vec![0x07, 0x00, 0x00, 0x00];
346        for unit in s.encode_utf16() {
347            bytes.extend_from_slice(&unit.to_le_bytes());
348        }
349        bytes
350    }
351
352    #[test]
353    fn test_parse_efi_var() {
354        // A plain value, NUL-terminated as the firmware writes it.
355        let bytes = efi_var_bytes("abcd-1234\0");
356        assert_eq!(parse_efi_var(&bytes[..]).unwrap(), "abcd-1234");
357
358        // Surrounding whitespace and trailing NULs are stripped.
359        let bytes = efi_var_bytes("  value \0\0");
360        assert_eq!(parse_efi_var(&bytes[..]).unwrap(), "value");
361
362        // Just the attributes with no data is empty, not an error.
363        let bytes = efi_var_bytes("");
364        assert_eq!(parse_efi_var(&bytes[..]).unwrap(), "");
365    }
366
367    #[test]
368    fn test_parse_efi_var_errors() {
369        // Fewer than the 4 attribute bytes.
370        assert!(matches!(
371            parse_efi_var(&[0x07, 0x00][..]),
372            Err(EfiError::InvalidData(_))
373        ));
374
375        // Odd number of data bytes cannot be UTF-16 LE.
376        let bytes = [0x07, 0x00, 0x00, 0x00, 0x41];
377        assert!(matches!(
378            parse_efi_var(&bytes[..]),
379            Err(EfiError::InvalidData(_))
380        ));
381
382        // Unpaired UTF-16 surrogate is not valid UTF-16.
383        let bytes = [0x07, 0x00, 0x00, 0x00, 0x00, 0xD8];
384        assert!(matches!(
385            parse_efi_var(&bytes[..]),
386            Err(EfiError::InvalidData(_))
387        ));
388    }
389
390    #[test]
391    fn test_find_mount_option() {
392        const V1: &str = "rw,relatime,compress=foo,subvol=blah,fast";
393        assert_eq!(find_mount_option(V1, "subvol").unwrap(), "blah");
394        assert_eq!(find_mount_option(V1, "rw"), None);
395        assert_eq!(find_mount_option(V1, "somethingelse"), None);
396    }
397
398    #[test]
399    fn test_sigpolicy_from_opts() {
400        assert_eq!(sigpolicy_from_opt(true), SignatureSource::ContainerPolicy);
401        assert_eq!(
402            sigpolicy_from_opt(false),
403            SignatureSource::ContainerPolicyAllowInsecure
404        );
405    }
406
407    #[test]
408    fn test_relative_path() {
409        let from = Path::new("/sysroot/state/deploy/image_id");
410        let to = Path::new("/sysroot/state/os/default/var");
411
412        assert_eq!(
413            path_relative_to(from, to).unwrap(),
414            PathBuf::from("../../os/default/var")
415        );
416        assert_eq!(
417            path_relative_to(&Path::new("state/deploy"), to)
418                .unwrap_err()
419                .to_string(),
420            "Paths must be absolute"
421        );
422    }
423
424    #[test]
425    fn test_have_executable() {
426        assert!(have_executable("true").unwrap());
427        assert!(!have_executable("someexethatdoesnotexist").unwrap());
428    }
429
430    #[test]
431    fn test_have_executable_in_root() -> anyhow::Result<()> {
432        use cap_std_ext::cap_std;
433
434        let root = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
435        assert!(!have_executable_in_root(&root, "podman")?);
436
437        root.create_dir_all("usr/bin")?;
438        root.write("usr/bin/podman", "")?;
439        assert!(have_executable_in_root(&root, "podman")?);
440        assert!(!have_executable_in_root(&root, "missing")?);
441
442        // An absolute name replaces each PATH entry via PathBuf::push, so the
443        // candidate is independent of the process PATH.
444        const PROBE: &str = "/usr/local/bin/probe";
445        let cases = [
446            ("present", Some(true)),
447            ("absent", Some(false)),
448            ("escapes", None),
449            ("contained-symlink", Some(true)),
450        ];
451        for (label, expected) in cases {
452            let root = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
453            match label {
454                "present" => {
455                    root.create_dir_all("usr/local/bin")?;
456                    root.write("usr/local/bin/probe", "")?;
457                }
458                "absent" => {}
459                "escapes" => {
460                    root.create_dir("usr")?;
461                    root.symlink_contents("../../var/usrlocal", "usr/local")?;
462                }
463                "contained-symlink" => {
464                    root.create_dir_all("var/usrlocal/bin")?;
465                    root.write("var/usrlocal/bin/probe", "")?;
466                    root.create_dir("usr")?;
467                    root.symlink_contents("../var/usrlocal", "usr/local")?;
468                }
469                _ => unreachable!("{label}"),
470            }
471            let found = have_executable_in_root(&root, PROBE);
472            match expected {
473                Some(expected) => assert_eq!(found?, expected, "{label}"),
474                None => {
475                    let err = found.expect_err(label);
476                    assert!(
477                        err.chain()
478                            .any(|source| source.to_string().contains("usr/local/bin/probe")),
479                        "{label}: {err:?}"
480                    );
481                    assert!(
482                        err.chain().any(|source| {
483                            source
484                                .downcast_ref::<std::io::Error>()
485                                .is_some_and(|io_err| {
486                                    io_err
487                                        .to_string()
488                                        .contains("a path led outside of the filesystem")
489                                })
490                        }),
491                        "{label}: {err:?}"
492                    );
493                }
494            }
495        }
496        Ok(())
497    }
498}