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