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::fmt::Write as _;
10use std::future::Future;
11use std::num::NonZeroUsize;
12use std::pin::Pin;
13
14use bootc_utils::collect_until;
15use camino::Utf8PathBuf;
16use cap_std::fs::{Dir, MetadataExt as _};
17use cap_std_ext::cap_std;
18use cap_std_ext::dirext::CapStdExtDirExt;
19use fn_error_context::context;
20use linkme::distributed_slice;
21use ostree_ext::ostree_prepareroot::Tristate;
22use ostree_ext::{composefs, ostree};
23
24use crate::store::Storage;
25
26use std::os::fd::AsFd;
27
28/// A lint check has failed.
29#[derive(thiserror::Error, Debug)]
30struct FsckError(String);
31
32/// The outer error is for unexpected fatal runtime problems; the
33/// inner error is for the check failing in an expected way.
34type FsckResult = anyhow::Result<std::result::Result<(), FsckError>>;
35
36/// Everything is OK - we didn't encounter a runtime error, and
37/// the targeted check passed.
38fn fsck_ok() -> FsckResult {
39    Ok(Ok(()))
40}
41
42/// We successfully found a failure.
43fn fsck_err(msg: impl AsRef<str>) -> FsckResult {
44    Ok(Err(FsckError::new(msg)))
45}
46
47impl std::fmt::Display for FsckError {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        f.write_str(&self.0)
50    }
51}
52
53impl FsckError {
54    fn new(msg: impl AsRef<str>) -> Self {
55        Self(msg.as_ref().to_owned())
56    }
57}
58
59type FsckFn = fn(&Storage) -> FsckResult;
60type AsyncFsckFn = fn(&Storage) -> Pin<Box<dyn Future<Output = FsckResult> + '_>>;
61#[derive(Debug)]
62enum FsckFnImpl {
63    Sync(FsckFn),
64    Async(AsyncFsckFn),
65}
66
67impl From<FsckFn> for FsckFnImpl {
68    fn from(value: FsckFn) -> Self {
69        Self::Sync(value)
70    }
71}
72
73impl From<AsyncFsckFn> for FsckFnImpl {
74    fn from(value: AsyncFsckFn) -> Self {
75        Self::Async(value)
76    }
77}
78
79#[derive(Debug)]
80struct FsckCheck {
81    name: &'static str,
82    ordering: u16,
83    f: FsckFnImpl,
84}
85
86#[distributed_slice]
87pub(crate) static FSCK_CHECKS: [FsckCheck];
88
89impl FsckCheck {
90    pub(crate) const fn new(name: &'static str, ordering: u16, f: FsckFnImpl) -> Self {
91        FsckCheck { name, ordering, f }
92    }
93}
94
95#[distributed_slice(FSCK_CHECKS)]
96static CHECK_RESOLVCONF: FsckCheck =
97    FsckCheck::new("etc-resolvconf", 5, FsckFnImpl::Sync(check_resolvconf));
98/// See https://github.com/bootc-dev/bootc/pull/1096 and https://github.com/containers/bootc/pull/1167
99/// Basically verify that if /usr/etc/resolv.conf exists, it is not a zero-sized file that was
100/// probably injected by buildah and that bootc should have removed.
101///
102/// Note that this fsck check can fail for systems upgraded from old bootc right now, as
103/// we need the *new* bootc to fix it.
104///
105/// But at the current time fsck is an experimental feature that we should only be running
106/// in our CI.
107fn check_resolvconf(storage: &Storage) -> FsckResult {
108    let ostree = storage.get_ostree()?;
109    // For now we only check the booted deployment.
110    if ostree.booted_deployment().is_none() {
111        return fsck_ok();
112    }
113    // Read usr/etc/resolv.conf directly.
114    let usr = Dir::open_ambient_dir("/usr", cap_std::ambient_authority())?;
115    let Some(meta) = usr.symlink_metadata_optional("etc/resolv.conf")? else {
116        return fsck_ok();
117    };
118    if meta.is_file() && meta.size() == 0 {
119        return fsck_err("Found usr/etc/resolv.conf as zero-sized file");
120    }
121    fsck_ok()
122}
123
124#[derive(Debug, Default)]
125struct ObjectsVerityState {
126    /// Count of objects with fsverity
127    enabled: u64,
128    /// Count of objects without fsverity
129    disabled: u64,
130    /// Objects which should have fsverity but do not
131    missing: Vec<String>,
132}
133
134/// Check the fsverity state of all regular files in this object directory.
135#[context("Computing verity state")]
136fn verity_state_of_objects(
137    d: &Dir,
138    prefix: &str,
139    expected: bool,
140) -> anyhow::Result<ObjectsVerityState> {
141    let mut enabled = 0;
142    let mut disabled = 0;
143    let mut missing = Vec::new();
144    for ent in d.entries()? {
145        let ent = ent?;
146        if !ent.file_type()?.is_file() {
147            continue;
148        }
149        let name = ent.file_name();
150        let name = name
151            .into_string()
152            .map(Utf8PathBuf::from)
153            .map_err(|_| anyhow::anyhow!("Invalid UTF-8"))?;
154        let Some("file") = name.extension() else {
155            continue;
156        };
157        let f = d.open(&name)?;
158        let r: Option<composefs::fsverity::Sha256HashValue> =
159            composefs::fsverity::measure_verity_opt(f.as_fd())?;
160        drop(f);
161        if r.is_some() {
162            enabled += 1;
163        } else {
164            disabled += 1;
165            if expected {
166                missing.push(format!("{prefix}{name}"));
167            }
168        }
169    }
170    let r = ObjectsVerityState {
171        enabled,
172        disabled,
173        missing,
174    };
175    Ok(r)
176}
177
178async fn verity_state_of_all_objects(
179    repo: &ostree::Repo,
180    expected: bool,
181) -> anyhow::Result<ObjectsVerityState> {
182    // Limit concurrency here
183    const MAX_CONCURRENT: usize = 3;
184
185    let repodir = Dir::reopen_dir(&repo.dfd_borrow())?;
186
187    // It's convenient here to reuse tokio's spawn_blocking as a threadpool basically.
188    let mut joinset = tokio::task::JoinSet::new();
189    let mut results = Vec::new();
190
191    for ent in repodir.read_dir("objects")? {
192        // Block here if the queue is full
193        while joinset.len() >= MAX_CONCURRENT {
194            results.push(joinset.join_next().await.unwrap()??);
195        }
196        let ent = ent?;
197        if !ent.file_type()?.is_dir() {
198            continue;
199        }
200        let name = ent.file_name();
201        let name = name
202            .into_string()
203            .map(Utf8PathBuf::from)
204            .map_err(|_| anyhow::anyhow!("Invalid UTF-8"))?;
205
206        let objdir = ent.open_dir()?;
207        joinset.spawn_blocking(move || verity_state_of_objects(&objdir, name.as_str(), expected));
208    }
209
210    // Drain the remaining tasks.
211    while let Some(output) = joinset.join_next().await {
212        results.push(output??);
213    }
214    // Fold the results.
215    let r = results
216        .into_iter()
217        .fold(ObjectsVerityState::default(), |mut acc, v| {
218            acc.enabled += v.enabled;
219            acc.disabled += v.disabled;
220            acc.missing.extend(v.missing);
221            acc
222        });
223    Ok(r)
224}
225
226#[distributed_slice(FSCK_CHECKS)]
227static CHECK_FSVERITY: FsckCheck =
228    FsckCheck::new("fsverity", 10, FsckFnImpl::Async(check_fsverity));
229fn check_fsverity(storage: &Storage) -> Pin<Box<dyn Future<Output = FsckResult> + '_>> {
230    Box::pin(check_fsverity_inner(storage))
231}
232
233async fn check_fsverity_inner(storage: &Storage) -> FsckResult {
234    let ostree = storage.get_ostree()?;
235    let repo = &ostree.repo();
236    let verity_state = ostree_ext::fsverity::is_verity_enabled(repo)?;
237    tracing::debug!(
238        "verity: expected={:?} found={:?}",
239        verity_state.desired,
240        verity_state.enabled
241    );
242
243    let verity_found_state =
244        verity_state_of_all_objects(&ostree.repo(), verity_state.desired == Tristate::Enabled)
245            .await?;
246    let Some((missing, rest)) = collect_until(
247        verity_found_state.missing.iter(),
248        const { NonZeroUsize::new(5).unwrap() },
249    ) else {
250        return fsck_ok();
251    };
252    let mut err = String::from("fsverity enabled, but objects without fsverity:\n");
253    for obj in missing {
254        // SAFETY: Writing into a String
255        writeln!(err, "  {obj}").unwrap();
256    }
257    if rest > 0 {
258        // SAFETY: Writing into a String
259        writeln!(err, "  ...and {rest} more").unwrap();
260    }
261    fsck_err(err)
262}
263
264pub(crate) async fn fsck(storage: &Storage, mut output: impl std::io::Write) -> anyhow::Result<()> {
265    let mut checks = FSCK_CHECKS.static_slice().iter().collect::<Vec<_>>();
266    checks.sort_by(|a, b| a.ordering.cmp(&b.ordering));
267
268    let mut errors = false;
269    for check in checks.iter() {
270        let name = check.name;
271        let r = match check.f {
272            FsckFnImpl::Sync(f) => f(&storage),
273            FsckFnImpl::Async(f) => f(&storage).await,
274        };
275        match r {
276            Ok(Ok(())) => {
277                println!("ok: {name}");
278            }
279            Ok(Err(e)) => {
280                errors = true;
281                writeln!(output, "fsck error: {name}: {e}")?;
282            }
283            Err(e) => {
284                errors = true;
285                writeln!(output, "Unexpected runtime error in check {name}: {e}")?;
286            }
287        }
288    }
289    if errors {
290        anyhow::bail!("Encountered errors")
291    }
292
293    // Run an `ostree fsck` (yes, ostree exposes enough APIs
294    // that we could reimplement this in Rust, but eh)
295    // TODO: Fix https://github.com/bootc-dev/bootc/issues/1216 so we can
296    // do this.
297    // let st = Command::new("ostree")
298    //     .arg("fsck")
299    //     .stdin(std::process::Stdio::inherit())
300    //     .status()?;
301    // if !st.success() {
302    //     anyhow::bail!("ostree fsck failed");
303    // }
304
305    Ok(())
306}