Skip to main content

bootc_lib/
fsck.rs

1//! # Perform consistency checking.
2//!
3//! This is an internal module, backing the experimental `bootc internals fsck`
4//! command.
5
6// Unfortunately needed here to work with linkme
7#![allow(unsafe_code)]
8
9use std::future::Future;
10use std::num::NonZeroUsize;
11use std::pin::Pin;
12
13use bootc_utils::collect_until;
14use camino::Utf8PathBuf;
15use cap_std::fs::{Dir, MetadataExt as _};
16use cap_std_ext::cap_std;
17use cap_std_ext::dirext::CapStdExtDirExt;
18use composefs_ctl::composefs;
19use fn_error_context::context;
20use linkme::distributed_slice;
21use ostree_ext::ostree;
22use ostree_ext::ostree_prepareroot::Tristate;
23
24use crate::store::Storage;
25
26use std::os::fd::AsFd;
27
28/// The kind of problem a check found.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub(crate) enum CheckFindingCode {
31    /// `/usr/etc/resolv.conf` is a zero-sized file.
32    ResolvconfZeroSized,
33    /// fsverity is enabled for the ostree repository, but not on an object.
34    ObjectWithoutFsverity,
35}
36
37impl std::fmt::Display for CheckFindingCode {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        std::fmt::Debug::fmt(self, f)
40    }
41}
42
43/// A problem a check found in the system.
44#[derive(Debug, PartialEq, Eq)]
45pub(crate) struct CheckFinding {
46    code: CheckFindingCode,
47    /// What the problem is in, e.g. a path or an object.
48    subject: String,
49    detail: String,
50}
51
52impl CheckFinding {
53    fn new(code: CheckFindingCode, subject: impl Into<String>, detail: impl Into<String>) -> Self {
54        Self {
55            code,
56            subject: subject.into(),
57            detail: detail.into(),
58        }
59    }
60}
61
62impl std::fmt::Display for CheckFinding {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        write!(f, "{}: {}: {}", self.code, self.subject, self.detail)
65    }
66}
67
68/// Like the lints, checks have two levels of errors: the outer `Result` is
69/// for unexpected runtime errors, which stop the check; every problem the
70/// check finds in the system is collected as a finding, and the check runs
71/// to completion.  No findings means the check passed.
72pub(crate) type FsckResult = anyhow::Result<Vec<CheckFinding>>;
73
74/// How many findings of one check are printed; the rest are counted.
75const MAX_PRINTED_FINDINGS: NonZeroUsize = NonZeroUsize::new(5).unwrap();
76
77pub(crate) type FsckFn = fn(&Storage) -> FsckResult;
78pub(crate) type AsyncFsckFn = fn(&Storage) -> Pin<Box<dyn Future<Output = FsckResult> + '_>>;
79#[derive(Debug)]
80pub(crate) enum FsckFnImpl {
81    Sync(FsckFn),
82    Async(AsyncFsckFn),
83}
84
85impl From<FsckFn> for FsckFnImpl {
86    fn from(value: FsckFn) -> Self {
87        Self::Sync(value)
88    }
89}
90
91impl From<AsyncFsckFn> for FsckFnImpl {
92    fn from(value: AsyncFsckFn) -> Self {
93        Self::Async(value)
94    }
95}
96
97#[derive(Debug)]
98pub(crate) struct FsckCheck {
99    name: &'static str,
100    ordering: u16,
101    f: FsckFnImpl,
102}
103
104#[distributed_slice]
105pub(crate) static FSCK_CHECKS: [FsckCheck];
106
107impl FsckCheck {
108    pub(crate) const fn new(name: &'static str, ordering: u16, f: FsckFnImpl) -> Self {
109        FsckCheck { name, ordering, f }
110    }
111}
112
113#[distributed_slice(FSCK_CHECKS)]
114static CHECK_RESOLVCONF: FsckCheck =
115    FsckCheck::new("etc-resolvconf", 5, FsckFnImpl::Sync(check_resolvconf));
116/// See <https://github.com/bootc-dev/bootc/pull/1096> and <https://github.com/containers/bootc/pull/1167>
117/// Basically verify that if /usr/etc/resolv.conf exists, it is not a zero-sized file that was
118/// probably injected by buildah and that bootc should have removed.
119///
120/// Note that this fsck check can fail for systems upgraded from old bootc right now, as
121/// we need the *new* bootc to fix it.
122///
123/// But at the current time fsck is an experimental feature that we should only be running
124/// in our CI.
125fn check_resolvconf(storage: &Storage) -> FsckResult {
126    let ostree = match storage.get_ostree() {
127        Ok(o) => o,
128        Err(_) => return Ok(Vec::new()), // Not an ostree system (e.g. composefs-only)
129    };
130    // For now we only check the booted deployment.
131    if ostree.booted_deployment().is_none() {
132        return Ok(Vec::new());
133    }
134    let usr = Dir::open_ambient_dir("/usr", cap_std::ambient_authority())?;
135    check_resolvconf_in(&usr)
136}
137
138/// The subject of the resolv.conf check's finding.
139const RESOLVCONF_SUBJECT: &str = "usr/etc/resolv.conf";
140
141/// The resolv.conf check proper, on the `/usr` directory `usr`.
142fn check_resolvconf_in(usr: &Dir) -> FsckResult {
143    // Read usr/etc/resolv.conf directly.
144    let Some(meta) = usr.symlink_metadata_optional("etc/resolv.conf")? else {
145        return Ok(Vec::new());
146    };
147    if meta.is_file() && meta.size() == 0 {
148        return Ok(vec![CheckFinding::new(
149            CheckFindingCode::ResolvconfZeroSized,
150            RESOLVCONF_SUBJECT,
151            "zero-sized file",
152        )]);
153    }
154    Ok(Vec::new())
155}
156
157#[derive(Debug, Default)]
158struct ObjectsVerityState {
159    /// Count of objects with fsverity
160    enabled: u64,
161    /// Count of objects without fsverity
162    disabled: u64,
163    /// Objects which should have fsverity but do not
164    missing: Vec<String>,
165}
166
167/// Check the fsverity state of all regular files in this object directory.
168#[context("Computing verity state")]
169fn verity_state_of_objects(
170    d: &Dir,
171    prefix: &str,
172    expected: bool,
173) -> anyhow::Result<ObjectsVerityState> {
174    let mut enabled = 0;
175    let mut disabled = 0;
176    let mut missing = Vec::new();
177    for ent in d.entries()? {
178        let ent = ent?;
179        if !ent.file_type()?.is_file() {
180            continue;
181        }
182        let name = ent.file_name();
183        let name = name
184            .into_string()
185            .map(Utf8PathBuf::from)
186            .map_err(|_| anyhow::anyhow!("Invalid UTF-8"))?;
187        let Some("file") = name.extension() else {
188            continue;
189        };
190        let f = d.open(&name)?;
191        let r: Option<composefs::fsverity::Sha256HashValue> =
192            composefs::fsverity::measure_verity_opt(f.as_fd())?;
193        drop(f);
194        if r.is_some() {
195            enabled += 1;
196        } else {
197            disabled += 1;
198            if expected {
199                missing.push(format!("{prefix}{name}"));
200            }
201        }
202    }
203    let r = ObjectsVerityState {
204        enabled,
205        disabled,
206        missing,
207    };
208    Ok(r)
209}
210
211async fn verity_state_of_all_objects(
212    repo: &ostree::Repo,
213    expected: bool,
214) -> anyhow::Result<ObjectsVerityState> {
215    // Limit concurrency here
216    const MAX_CONCURRENT: usize = 3;
217
218    let repodir = Dir::reopen_dir(&repo.dfd_borrow())?;
219
220    // It's convenient here to reuse tokio's spawn_blocking as a threadpool basically.
221    let mut joinset = tokio::task::JoinSet::new();
222    let mut results = Vec::new();
223
224    for ent in repodir.read_dir("objects")? {
225        // Block here if the queue is full
226        while joinset.len() >= MAX_CONCURRENT {
227            results.push(joinset.join_next().await.unwrap()??);
228        }
229        let ent = ent?;
230        if !ent.file_type()?.is_dir() {
231            continue;
232        }
233        let name = ent.file_name();
234        let name = name
235            .into_string()
236            .map(Utf8PathBuf::from)
237            .map_err(|_| anyhow::anyhow!("Invalid UTF-8"))?;
238
239        let objdir = ent.open_dir()?;
240        joinset.spawn_blocking(move || verity_state_of_objects(&objdir, name.as_str(), expected));
241    }
242
243    // Drain the remaining tasks.
244    while let Some(output) = joinset.join_next().await {
245        results.push(output??);
246    }
247    // Fold the results.
248    let r = results
249        .into_iter()
250        .fold(ObjectsVerityState::default(), |mut acc, v| {
251            acc.enabled += v.enabled;
252            acc.disabled += v.disabled;
253            acc.missing.extend(v.missing);
254            acc
255        });
256    Ok(r)
257}
258
259#[distributed_slice(FSCK_CHECKS)]
260static CHECK_FSVERITY: FsckCheck =
261    FsckCheck::new("fsverity", 10, FsckFnImpl::Async(check_fsverity));
262fn check_fsverity(storage: &Storage) -> Pin<Box<dyn Future<Output = FsckResult> + '_>> {
263    Box::pin(check_fsverity_inner(storage))
264}
265
266async fn check_fsverity_inner(storage: &Storage) -> FsckResult {
267    let ostree = match storage.get_ostree() {
268        Ok(o) => o,
269        Err(_) => return Ok(Vec::new()), // Not an ostree system (e.g. composefs-only)
270    };
271    let repo = &ostree.repo();
272    let verity_state = ostree_ext::fsverity::is_verity_enabled(repo)?;
273    tracing::debug!(
274        "verity: expected={:?} found={:?}",
275        verity_state.desired,
276        verity_state.enabled
277    );
278
279    let verity_found_state =
280        verity_state_of_all_objects(&ostree.repo(), verity_state.desired == Tristate::Enabled)
281            .await?;
282    let findings = verity_found_state
283        .missing
284        .into_iter()
285        .map(|obj| {
286            CheckFinding::new(
287                CheckFindingCode::ObjectWithoutFsverity,
288                obj,
289                "missing fsverity, which is enabled for the repository",
290            )
291        })
292        .collect();
293    Ok(findings)
294}
295
296/// Print the result of the check `name`, returning whether it passed.
297fn print_check_result(
298    name: &str,
299    result: &FsckResult,
300    mut output: impl std::io::Write,
301) -> std::io::Result<bool> {
302    let findings = match result {
303        Ok(findings) => findings,
304        Err(e) => {
305            writeln!(output, "Unexpected runtime error in check {name}: {e:#}")?;
306            return Ok(false);
307        }
308    };
309    let Some((shown, rest)) = collect_until(findings.iter(), MAX_PRINTED_FINDINGS) else {
310        writeln!(output, "ok: {name}")?;
311        return Ok(true);
312    };
313    for finding in shown {
314        writeln!(output, "fsck error: {name}: {finding}")?;
315    }
316    if rest > 0 {
317        writeln!(output, "fsck error: {name}: ...and {rest} more")?;
318    }
319    Ok(false)
320}
321
322pub(crate) async fn fsck(storage: &Storage, mut output: impl std::io::Write) -> anyhow::Result<()> {
323    let mut checks = FSCK_CHECKS.static_slice().iter().collect::<Vec<_>>();
324    checks.sort_by(|a, b| a.ordering.cmp(&b.ordering));
325
326    let mut errors = false;
327    for check in checks.iter() {
328        let name = check.name;
329        let r = match check.f {
330            FsckFnImpl::Sync(f) => f(&storage),
331            FsckFnImpl::Async(f) => f(&storage).await,
332        };
333        if !print_check_result(name, &r, &mut output)? {
334            errors = true;
335        }
336    }
337    if errors {
338        anyhow::bail!("Encountered errors")
339    }
340
341    // Run an `ostree fsck` (yes, ostree exposes enough APIs
342    // that we could reimplement this in Rust, but eh)
343    // TODO: Fix https://github.com/bootc-dev/bootc/issues/1216 so we can
344    // do this.
345    // let st = Command::new("ostree")
346    //     .arg("fsck")
347    //     .stdin(std::process::Stdio::inherit())
348    //     .status()?;
349    // if !st.success() {
350    //     anyhow::bail!("ostree fsck failed");
351    // }
352
353    Ok(())
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use cap_std_ext::cap_tempfile;
360
361    #[test]
362    fn test_check_resolvconf() -> anyhow::Result<()> {
363        let zero_sized = || {
364            vec![CheckFinding::new(
365                CheckFindingCode::ResolvconfZeroSized,
366                RESOLVCONF_SUBJECT,
367                "zero-sized file",
368            )]
369        };
370        // Sets up etc/resolv.conf (if anything) in a fresh /usr
371        type Setup = fn(&Dir) -> std::io::Result<()>;
372        let cases: &[(&str, Setup, Vec<CheckFinding>)] = &[
373            ("missing", |_| Ok(()), Vec::new()),
374            (
375                "zero-sized",
376                |d| d.write("etc/resolv.conf", ""),
377                zero_sized(),
378            ),
379            (
380                "nonempty",
381                |d| d.write("etc/resolv.conf", "nameserver 192.0.2.1\n"),
382                Vec::new(),
383            ),
384            (
385                "symlink",
386                |d| d.symlink("../run/resolv.conf", "etc/resolv.conf"),
387                Vec::new(),
388            ),
389            ("directory", |d| d.create_dir("etc/resolv.conf"), Vec::new()),
390        ];
391        for (name, setup, expected) in cases {
392            let usr = cap_tempfile::tempdir(cap_std::ambient_authority())?;
393            usr.create_dir("etc")?;
394            setup(&usr)?;
395            assert_eq!(&check_resolvconf_in(&usr)?, expected, "case {name}");
396        }
397        Ok(())
398    }
399
400    #[test]
401    fn test_print_check_result() {
402        let objects = |n: usize| -> FsckResult {
403            Ok((0..n)
404                .map(|i| {
405                    CheckFinding::new(
406                        CheckFindingCode::ObjectWithoutFsverity,
407                        format!("{i:02}.file"),
408                        "detail",
409                    )
410                })
411                .collect())
412        };
413        let cases: &[(&str, FsckResult, bool, &str)] = &[
414            ("no findings", Ok(Vec::new()), true, "ok: c\n"),
415            (
416                "one finding",
417                objects(1),
418                false,
419                "fsck error: c: ObjectWithoutFsverity: 00.file: detail\n",
420            ),
421            (
422                "at the limit",
423                objects(5),
424                false,
425                indoc::indoc! {"
426                    fsck error: c: ObjectWithoutFsverity: 00.file: detail
427                    fsck error: c: ObjectWithoutFsverity: 01.file: detail
428                    fsck error: c: ObjectWithoutFsverity: 02.file: detail
429                    fsck error: c: ObjectWithoutFsverity: 03.file: detail
430                    fsck error: c: ObjectWithoutFsverity: 04.file: detail
431                "},
432            ),
433            (
434                "over the limit",
435                objects(8),
436                false,
437                indoc::indoc! {"
438                    fsck error: c: ObjectWithoutFsverity: 00.file: detail
439                    fsck error: c: ObjectWithoutFsverity: 01.file: detail
440                    fsck error: c: ObjectWithoutFsverity: 02.file: detail
441                    fsck error: c: ObjectWithoutFsverity: 03.file: detail
442                    fsck error: c: ObjectWithoutFsverity: 04.file: detail
443                    fsck error: c: ...and 3 more
444                "},
445            ),
446            (
447                "runtime error",
448                Err(anyhow::anyhow!("inner").context("outer")),
449                false,
450                "Unexpected runtime error in check c: outer: inner\n",
451            ),
452        ];
453        for (name, result, passed, expected) in cases {
454            let mut output = Vec::new();
455            let r = print_check_result("c", result, &mut output).unwrap();
456            assert_eq!(r, *passed, "case {name}");
457            assert_eq!(String::from_utf8(output).unwrap(), *expected, "case {name}");
458        }
459    }
460}