Skip to main content

ostree_ext/
ostree_prepareroot.rs

1//! Logic related to parsing ostree-prepare-root.conf.
2//!
3
4// SPDX-License-Identifier: Apache-2.0 OR MIT
5
6use std::io::Read;
7use std::os::fd::{AsFd, OwnedFd};
8use std::str::FromStr;
9
10use anyhow::{Context, Result};
11use camino::Utf8Path;
12use cap_std_ext::dirext::CapStdExtDirExt;
13use fn_error_context::context;
14use ocidir::cap_std::fs::Dir;
15use ostree::glib::object::Cast;
16use ostree::prelude::FileExt;
17use ostree::{gio, glib};
18
19use crate::composefs::mount::{MountOptions, VerityRequirement, composefs_fsmount};
20use crate::keyfileext::KeyFileExt;
21use crate::ostree_manual;
22use bootc_utils::ResultExt;
23
24/// The relative path to ostree-prepare-root's config.
25pub const CONF_PATH: &str = "ostree/prepare-root.conf";
26
27/// The composefs image ostree writes into each deployment directory, which
28/// ostree-prepare-root mounts as the root filesystem.
29pub const COMPOSEFS_IMAGE: &str = ".ostree.cfs";
30
31/// Load the ostree prepare-root config from the given ostree repository.
32pub fn load_config(root: &ostree::RepoFile) -> Result<Option<glib::KeyFile>> {
33    let cancellable = gio::Cancellable::NONE;
34    let kf = glib::KeyFile::new();
35    for path in ["etc", "usr/lib"].into_iter().map(Utf8Path::new) {
36        let path = &path.join(CONF_PATH);
37        let f = root.resolve_relative_path(path);
38        if !f.query_exists(cancellable) {
39            continue;
40        }
41        let f = f.downcast_ref::<ostree::RepoFile>().unwrap();
42        let contents = ostree_manual::repo_file_read_to_string(f)?;
43        kf.load_from_data(&contents, glib::KeyFileFlags::NONE)
44            .with_context(|| format!("Parsing {path}"))?;
45        tracing::debug!("Loaded {path}");
46        return Ok(Some(kf));
47    }
48    tracing::debug!("No {CONF_PATH} found");
49    Ok(None)
50}
51
52/// Load the configuration from the target root.
53pub fn load_config_from_root(root: &Dir) -> Result<Option<glib::KeyFile>> {
54    for path in ["etc", "usr/lib"].into_iter().map(Utf8Path::new) {
55        let path = path.join(CONF_PATH);
56        let Some(mut f) = root.open_optional(&path)? else {
57            continue;
58        };
59        let mut contents = String::new();
60        f.read_to_string(&mut contents)?;
61        let kf = glib::KeyFile::new();
62        kf.load_from_data(&contents, glib::KeyFileFlags::NONE)
63            .with_context(|| format!("Parsing {path}"))?;
64        return Ok(Some(kf));
65    }
66    Ok(None)
67}
68
69/// Require the configuration in the target root.
70pub fn require_config_from_root(root: &Dir) -> Result<glib::KeyFile> {
71    load_config_from_root(root)?
72        .ok_or_else(|| anyhow::anyhow!("Failed to find {CONF_PATH} in /usr/lib or /etc"))
73}
74
75/// Query whether the target root has the `root.transient` key
76/// which sets up a transient overlayfs.
77pub fn overlayfs_root_enabled(root: &ostree::RepoFile) -> Result<bool> {
78    if let Some(config) = load_config(root)? {
79        overlayfs_enabled_in_config(&config)
80    } else {
81        Ok(false)
82    }
83}
84
85/// An option which can be enabled, disabled, or possibly enabled.
86#[derive(Debug, PartialEq, Eq, Clone)]
87pub enum Tristate {
88    /// Enabled
89    Enabled,
90    /// Disabled
91    Disabled,
92    /// Maybe
93    Maybe,
94}
95
96impl FromStr for Tristate {
97    type Err = anyhow::Error;
98
99    fn from_str(s: &str) -> Result<Self> {
100        let r = match s {
101            // Keep this in sync with ot_keyfile_get_tristate_with_default from ostree
102            "yes" | "true" | "1" => Tristate::Enabled,
103            "no" | "false" | "0" => Tristate::Disabled,
104            "maybe" => Tristate::Maybe,
105            o => anyhow::bail!("Invalid tristate value: {o}"),
106        };
107        Ok(r)
108    }
109}
110
111impl Default for Tristate {
112    fn default() -> Self {
113        Self::Disabled
114    }
115}
116
117impl Tristate {
118    pub(crate) fn maybe_enabled(&self) -> bool {
119        match self {
120            Self::Enabled | Self::Maybe => true,
121            Self::Disabled => false,
122        }
123    }
124}
125
126/// The state of a composefs for ostree
127#[derive(Debug, PartialEq, Eq)]
128pub enum ComposefsState {
129    /// The composefs must be signed and use fsverity
130    Signed,
131    /// The composefs must use fsverity
132    Verity,
133    /// The composefs may or may not be enabled.
134    Tristate(Tristate),
135}
136
137impl Default for ComposefsState {
138    fn default() -> Self {
139        Self::Tristate(Tristate::default())
140    }
141}
142
143impl FromStr for ComposefsState {
144    type Err = anyhow::Error;
145
146    #[context("Parsing composefs.enabled value {s}")]
147    fn from_str(s: &str) -> Result<Self> {
148        let r = match s {
149            "signed" => Self::Signed,
150            "verity" => Self::Verity,
151            o => Self::Tristate(Tristate::from_str(o)?),
152        };
153        Ok(r)
154    }
155}
156
157impl ComposefsState {
158    pub(crate) fn maybe_enabled(&self) -> bool {
159        match self {
160            ComposefsState::Signed | ComposefsState::Verity => true,
161            ComposefsState::Tristate(t) => t.maybe_enabled(),
162        }
163    }
164
165    /// This configuration requires fsverity on the target filesystem.
166    pub fn requires_fsverity(&self) -> bool {
167        matches!(self, ComposefsState::Signed | ComposefsState::Verity)
168    }
169}
170
171/// Query whether the config uses an overlayfs model (composefs or plain overlayfs).
172pub fn overlayfs_enabled_in_config(config: &glib::KeyFile) -> Result<bool> {
173    let root_transient = config
174        .optional_bool("root", "transient")?
175        .unwrap_or_default();
176    let composefs = composefs_state_in_config(config)
177        .log_err_default()
178        .unwrap_or_default();
179    Ok(root_transient || composefs.maybe_enabled())
180}
181
182/// Parse `composefs.enabled`, returning `None` if it is not set.
183pub fn composefs_state_in_config(config: &glib::KeyFile) -> Result<Option<ComposefsState>> {
184    config
185        .optional_string("composefs", "enabled")?
186        .map(|s| ComposefsState::from_str(s.as_str()))
187        .transpose()
188}
189
190/// Mount a deployment's composefs image the way ostree-prepare-root does in
191/// the initramfs: an overlayfs of [`COMPOSEFS_IMAGE`] whose file content comes
192/// from the repository's `objects` directory.
193///
194/// `config` is the deployment's prepare-root configuration. Returns a detached,
195/// read-only mount, or `None` if composefs is disabled there, or if the
196/// deployment has no image and the configuration does not require one.
197///
198/// This differs from ostree-prepare-root in two ways. An unset
199/// `composefs.enabled` uses the image when there is one (ostree-prepare-root
200/// treats unset as off), and `signed` only requires fs-verity: neither the
201/// commit signature nor the image digest is checked, as the public key lives
202/// in the initramfs. Kernel arguments are not consulted.
203pub fn mount_composefs(
204    deployment: &Dir,
205    repo: &Dir,
206    name: &str,
207    config: Option<&glib::KeyFile>,
208) -> Result<Option<OwnedFd>> {
209    let state = config.map(composefs_state_in_config).transpose()?.flatten();
210    if state == Some(ComposefsState::Tristate(Tristate::Disabled)) {
211        return Ok(None);
212    }
213    let Some(image) = deployment
214        .open_optional(COMPOSEFS_IMAGE)
215        .with_context(|| format!("Opening {COMPOSEFS_IMAGE}"))?
216    else {
217        let required = matches!(
218            state,
219            Some(ComposefsState::Signed | ComposefsState::Verity)
220                | Some(ComposefsState::Tristate(Tristate::Enabled))
221        );
222        anyhow::ensure!(
223            !required,
224            "composefs is enabled, but {COMPOSEFS_IMAGE} is missing"
225        );
226        return Ok(None);
227    };
228    let verity = if state
229        .as_ref()
230        .is_some_and(ComposefsState::requires_fsverity)
231    {
232        VerityRequirement::Required
233    } else {
234        VerityRequirement::Disabled
235    };
236    let objects = repo
237        .open_dir("objects")
238        .context("Opening repository objects")?;
239    let mount = composefs_fsmount(
240        image.into_std().into(),
241        name,
242        &[objects.as_fd()],
243        verity,
244        &MountOptions::default(),
245    )
246    .with_context(|| format!("Mounting {COMPOSEFS_IMAGE}"))?;
247    Ok(Some(mount))
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn test_tristate() {
256        for v in ["yes", "true", "1"] {
257            assert_eq!(Tristate::from_str(v).unwrap(), Tristate::Enabled);
258        }
259        assert_eq!(Tristate::from_str("maybe").unwrap(), Tristate::Maybe);
260        for v in ["no", "false", "0"] {
261            assert_eq!(Tristate::from_str(v).unwrap(), Tristate::Disabled);
262        }
263        for v in ["", "junk", "fal", "tr1"] {
264            assert!(Tristate::from_str(v).is_err());
265        }
266    }
267
268    #[test]
269    fn test_composefs_state() {
270        assert_eq!(
271            ComposefsState::from_str("signed").unwrap(),
272            ComposefsState::Signed
273        );
274        for v in ["yes", "true", "1"] {
275            assert_eq!(
276                ComposefsState::from_str(v).unwrap(),
277                ComposefsState::Tristate(Tristate::Enabled)
278            );
279        }
280        assert_eq!(Tristate::from_str("maybe").unwrap(), Tristate::Maybe);
281        for v in ["no", "false", "0"] {
282            assert_eq!(
283                ComposefsState::from_str(v).unwrap(),
284                ComposefsState::Tristate(Tristate::Disabled)
285            );
286        }
287    }
288
289    #[test]
290    fn test_overlayfs_enabled() {
291        let d0 = indoc::indoc! { r#"
292[foo]
293bar = baz
294[root]
295"# };
296        let d1 = indoc::indoc! { r#"
297[root]
298transient = false
299    "# };
300        let d2 = indoc::indoc! { r#"
301[composefs]
302enabled = false
303    "# };
304        for v in ["", d0, d1, d2] {
305            let kf = glib::KeyFile::new();
306            kf.load_from_data(v, glib::KeyFileFlags::empty()).unwrap();
307            assert!(!overlayfs_enabled_in_config(&kf).unwrap());
308        }
309
310        let e0 = format!("{d0}\n[root]\ntransient = true");
311        let e1 = format!("{d1}\n[composefs]\nenabled = true\n[other]\nsomekey = someval");
312        let e2 = format!("{d1}\n[composefs]\nenabled = yes");
313        let e3 = format!("{d1}\n[composefs]\nenabled = signed");
314        for v in [e0, e1, e2, e3] {
315            let kf = glib::KeyFile::new();
316            kf.load_from_data(&v, glib::KeyFileFlags::empty()).unwrap();
317            assert!(overlayfs_enabled_in_config(&kf).unwrap());
318        }
319    }
320
321    #[test]
322    fn test_mount_composefs_without_image() {
323        let td =
324            cap_std_ext::cap_tempfile::tempdir(cap_std_ext::cap_std::ambient_authority()).unwrap();
325        // (composefs.enabled, whether a missing image is an error)
326        let cases = [
327            (None, false),
328            (Some("no"), false),
329            (Some("maybe"), false),
330            (Some("yes"), true),
331            (Some("verity"), true),
332            (Some("signed"), true),
333        ];
334        for (enabled, err) in cases {
335            let kf = glib::KeyFile::new();
336            if let Some(v) = enabled {
337                kf.set_string("composefs", "enabled", v);
338            }
339            match mount_composefs(&td, &td, "test", Some(&kf)) {
340                Ok(m) => {
341                    assert!(!err, "{enabled:?}: expected an error");
342                    assert!(m.is_none(), "{enabled:?}");
343                }
344                Err(e) => assert!(err, "{enabled:?}: {e}"),
345            }
346        }
347        assert!(mount_composefs(&td, &td, "test", None).unwrap().is_none());
348        // An explicit "no" wins over an existing image, without mounting it.
349        td.write(COMPOSEFS_IMAGE, "").unwrap();
350        let kf = glib::KeyFile::new();
351        kf.set_string("composefs", "enabled", "no");
352        assert!(
353            mount_composefs(&td, &td, "test", Some(&kf))
354                .unwrap()
355                .is_none()
356        );
357    }
358}