Skip to content

Commit ff952c3

Browse files
committed
install: Honor composefs.enabled=verity
Key off the ostree prepare-root config to require fsverity on all objects. As part of this: - Add a dependency on composefs-rs just for the fsverity querying APIs, and as prep for further integration. - Add `bootc internals fsck`, which verifies the expected fsverity state. Signed-off-by: Colin Walters <[email protected]>
1 parent 728ab1a commit ff952c3

File tree

11 files changed

+379
-25
lines changed

11 files changed

+379
-25
lines changed

.github/workflows/ci.yml

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,21 @@ jobs:
7575
uses: actions/checkout@v4
7676
- name: Free up disk space on runner
7777
run: sudo ./ci/clean-gha-runner.sh
78+
- name: Enable fsverity for /
79+
run: sudo tune2fs -O verity $(findmnt -vno SOURCE /)
80+
- name: Install utils
81+
run: sudo apt -y install fsverity
7882
- name: Integration tests
7983
run: |
8084
set -xeu
85+
# Build images to test; TODO investigate doing single container builds
86+
# via GHA and pushing to a temporary registry to share among workflows?
8187
sudo podman build -t localhost/bootc -f hack/Containerfile .
88+
sudo podman build -t localhost/bootc-fsverity -f ci/Containerfile.install-fsverity
89+
8290
export CARGO_INCREMENTAL=0 # because we aren't caching the test runner bits
8391
cargo build --release -p tests-integration
92+
8493
df -h /
8594
sudo install -m 0755 target/release/tests-integration /usr/bin/bootc-integration-tests
8695
rm target -rf
@@ -90,8 +99,16 @@ jobs:
9099
-v /run/dbus:/run/dbus -v /run/systemd:/run/systemd localhost/bootc /src/ostree-ext/ci/priv-integration.sh
91100
# Nondestructive but privileged tests
92101
sudo bootc-integration-tests host-privileged localhost/bootc
93-
# Finally the install-alongside suite
102+
# Install tests
94103
sudo bootc-integration-tests install-alongside localhost/bootc
104+
105+
# And the fsverity case
106+
sudo podman run --privileged --pid=host localhost/bootc-fsverity bootc install to-existing-root --stateroot=other \
107+
--acknowledge-destructive --skip-fetch-check
108+
# Crude cross check
109+
sudo find /ostree/repo/objects -name '*.file' -type f | while read f; do
110+
sudo fsverity measure $f >/dev/null
111+
done
95112
docs:
96113
if: ${{ contains(github.event.pull_request.labels.*.name, 'documentation') }}
97114
runs-on: ubuntu-latest

Cargo.lock

Lines changed: 9 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ci/Containerfile.install-fsverity

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Enable fsverity at install time
2+
FROM localhost/bootc
3+
RUN <<EORUN
4+
set -xeuo pipefail
5+
cat > /usr/lib/ostree/prepare-root.conf <<EOF
6+
[composefs]
7+
enabled = verity
8+
EOF
9+
cat > /usr/lib/bootc/install/90-ext4.toml <<EOF
10+
[install.filesystem.root]
11+
type = "ext4"
12+
EOF
13+
bootc container lint
14+
EORUN

lib/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ chrono = { workspace = true, features = ["serde"] }
2626
clap = { workspace = true, features = ["derive","cargo"] }
2727
clap_mangen = { workspace = true, optional = true }
2828
#composefs = "0.2.0"
29-
composefs = { git = "https://github.com/containers/composefs-rs", rev = "55ae2e9ba72f6afda4887d746e6b98f0a1875ac4" }
3029
cap-std-ext = { workspace = true, features = ["fs_utf8"] }
3130
hex = { workspace = true }
3231
fn-error-context = { workspace = true }

lib/src/cli.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ use cap_std_ext::cap_std;
1313
use cap_std_ext::cap_std::fs::Dir;
1414
use clap::Parser;
1515
use clap::ValueEnum;
16-
use composefs::fsverity;
1716
use fn_error_context::context;
1817
use ostree::gio;
1918
use ostree_container::store::PrepareResult;
19+
use ostree_ext::composefs::fsverity;
2020
use ostree_ext::container as ostree_container;
2121
use ostree_ext::container_utils::ostree_booted;
2222
use ostree_ext::keyfileext::KeyFileExt;

lib/src/fsck.rs

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,26 @@
66
// Unfortunately needed here to work with linkme
77
#![allow(unsafe_code)]
88

9+
use std::fmt::Write as _;
910
use std::future::Future;
1011
use std::pin::Pin;
1112
use std::process::Command;
1213

14+
use bootc_utils::iterator_split_nonempty_rest_count;
15+
use camino::Utf8PathBuf;
1316
use cap_std::fs::{Dir, MetadataExt as _};
1417
use cap_std_ext::cap_std;
1518
use cap_std_ext::dirext::CapStdExtDirExt;
19+
use fn_error_context::context;
1620
use linkme::distributed_slice;
21+
use ostree_ext::ostree_prepareroot::Tristate;
22+
use ostree_ext::{composefs, ostree};
23+
use serde::{Deserialize, Serialize};
1724

1825
use crate::store::Storage;
1926

27+
use std::os::fd::AsFd;
28+
2029
/// A lint check has failed.
2130
#[derive(thiserror::Error, Debug)]
2231
struct FsckError(String);
@@ -112,6 +121,153 @@ fn check_resolvconf(storage: &Storage) -> FsckResult {
112121
fsck_ok()
113122
}
114123

124+
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
125+
#[serde(rename_all = "kebab-case")]
126+
pub(crate) enum VerityState {
127+
Enabled,
128+
Disabled,
129+
Inconsistent((u64, u64)),
130+
}
131+
132+
#[derive(Debug, Default)]
133+
struct ObjectsVerityState {
134+
/// Count of objects with fsverity
135+
enabled: u64,
136+
/// Count of objects without fsverity
137+
disabled: u64,
138+
/// Objects which should have fsverity but do not
139+
missing: Vec<String>,
140+
}
141+
142+
/// Check the fsverity state of all regular files in this object directory.
143+
#[context("Computing verity state")]
144+
fn verity_state_of_objects(
145+
d: &Dir,
146+
prefix: &str,
147+
expected: bool,
148+
) -> anyhow::Result<ObjectsVerityState> {
149+
let mut enabled = 0;
150+
let mut disabled = 0;
151+
let mut missing = Vec::new();
152+
for ent in d.entries()? {
153+
let ent = ent?;
154+
if !ent.file_type()?.is_file() {
155+
continue;
156+
}
157+
let name = ent.file_name();
158+
let name = name
159+
.into_string()
160+
.map(Utf8PathBuf::from)
161+
.map_err(|_| anyhow::anyhow!("Invalid UTF-8"))?;
162+
let Some("file") = name.extension() else {
163+
continue;
164+
};
165+
let f = d.open(&name)?;
166+
let r: Option<composefs::fsverity::Sha256HashValue> =
167+
composefs::fsverity::ioctl::fs_ioc_measure_verity(f.as_fd())?;
168+
drop(f);
169+
if r.is_some() {
170+
enabled += 1;
171+
} else {
172+
disabled += 1;
173+
if expected {
174+
missing.push(format!("{prefix}{name}"));
175+
}
176+
}
177+
}
178+
let r = ObjectsVerityState {
179+
enabled,
180+
disabled,
181+
missing,
182+
};
183+
Ok(r)
184+
}
185+
186+
async fn verity_state_of_all_objects(
187+
repo: &ostree::Repo,
188+
expected: bool,
189+
) -> anyhow::Result<ObjectsVerityState> {
190+
// Limit concurrency here
191+
const MAX_CONCURRENT: usize = 3;
192+
193+
let repodir = Dir::reopen_dir(&repo.dfd_borrow())?;
194+
195+
// It's convenient here to reuse tokio's spawn_blocking as a threadpool basically.
196+
let mut joinset = tokio::task::JoinSet::new();
197+
let mut results = Vec::new();
198+
199+
for ent in repodir.read_dir("objects")? {
200+
// Block here if the queue is full
201+
while joinset.len() >= MAX_CONCURRENT {
202+
results.push(joinset.join_next().await.unwrap()??);
203+
}
204+
let ent = ent?;
205+
if !ent.file_type()?.is_dir() {
206+
continue;
207+
}
208+
let name = ent.file_name();
209+
let name = name
210+
.into_string()
211+
.map(Utf8PathBuf::from)
212+
.map_err(|_| anyhow::anyhow!("Invalid UTF-8"))?;
213+
214+
let objdir = ent.open_dir()?;
215+
let expected = expected.clone();
216+
joinset.spawn_blocking(move || verity_state_of_objects(&objdir, name.as_str(), expected));
217+
}
218+
219+
// Drain the remaining tasks.
220+
while let Some(output) = joinset.join_next().await {
221+
results.push(output??);
222+
}
223+
// Fold the results.
224+
let r = results
225+
.into_iter()
226+
.fold(ObjectsVerityState::default(), |mut acc, v| {
227+
acc.enabled += v.enabled;
228+
acc.disabled += v.disabled;
229+
acc.missing.extend(v.missing);
230+
acc
231+
});
232+
Ok(r)
233+
}
234+
235+
#[distributed_slice(FSCK_CHECKS)]
236+
static CHECK_FSVERITY: FsckCheck =
237+
FsckCheck::new("fsverity", 10, FsckFnImpl::Async(check_fsverity));
238+
fn check_fsverity(storage: &Storage) -> Pin<Box<dyn Future<Output = FsckResult> + '_>> {
239+
Box::pin(check_fsverity_inner(storage))
240+
}
241+
242+
async fn check_fsverity_inner(storage: &Storage) -> FsckResult {
243+
let repo = &storage.repo();
244+
let verity_state = ostree_ext::fsverity::is_verity_enabled(repo)?;
245+
tracing::debug!(
246+
"verity: expected={:?} found={:?}",
247+
verity_state.desired,
248+
verity_state.enabled
249+
);
250+
251+
let verity_found_state =
252+
verity_state_of_all_objects(&storage.repo(), verity_state.desired == Tristate::Enabled)
253+
.await?;
254+
let Some((missing, rest)) =
255+
iterator_split_nonempty_rest_count(verity_found_state.missing.iter(), 5)
256+
else {
257+
return fsck_ok();
258+
};
259+
let mut err = String::from("fsverity enabled, but objects without fsverity:\n");
260+
for obj in missing {
261+
// SAFETY: Writing into a String
262+
writeln!(err, " {obj}").unwrap();
263+
}
264+
if rest > 0 {
265+
// SAFETY: Writing into a String
266+
writeln!(err, " ...and {rest} more").unwrap();
267+
}
268+
fsck_err(err)
269+
}
270+
115271
pub(crate) async fn fsck(storage: &Storage, mut output: impl std::io::Write) -> anyhow::Result<()> {
116272
let mut checks = FSCK_CHECKS.static_slice().iter().collect::<Vec<_>>();
117273
checks.sort_by(|a, b| a.ordering.cmp(&b.ordering));

lib/src/install.rs

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ use fn_error_context::context;
4040
use ostree::gio;
4141
use ostree_ext::oci_spec;
4242
use ostree_ext::ostree;
43+
use ostree_ext::ostree_prepareroot::{ComposefsState, Tristate};
4344
use ostree_ext::prelude::Cast;
4445
use ostree_ext::sysroot::SysrootLock;
4546
use ostree_ext::{container as ostree_container, ostree_prepareroot};
@@ -77,6 +78,15 @@ const SELINUXFS: &str = "/sys/fs/selinux";
7778
const EFIVARFS: &str = "/sys/firmware/efi/efivars";
7879
pub(crate) const ARCH_USES_EFI: bool = cfg!(any(target_arch = "x86_64", target_arch = "aarch64"));
7980

81+
const DEFAULT_REPO_CONFIG: &[(&str, &str)] = &[
82+
// Default to avoiding grub2-mkconfig etc.
83+
("sysroot.bootloader", "none"),
84+
// Always flip this one on because we need to support alongside installs
85+
// to systems without a separate boot partition.
86+
("sysroot.bootprefix", "true"),
87+
("sysroot.readonly", "true"),
88+
];
89+
8090
/// Kernel argument used to specify we want the rootfs mounted read-write by default
8191
const RW_KARG: &str = "rw";
8292

@@ -638,14 +648,7 @@ async fn initialize_ostree_root(state: &State, root_setup: &RootSetup) -> Result
638648
crate::lsm::ensure_dir_labeled(rootfs_dir, "boot", None, 0o755.into(), sepolicy)?;
639649
}
640650

641-
for (k, v) in [
642-
// Default to avoiding grub2-mkconfig etc.
643-
("sysroot.bootloader", "none"),
644-
// Always flip this one on because we need to support alongside installs
645-
// to systems without a separate boot partition.
646-
("sysroot.bootprefix", "true"),
647-
("sysroot.readonly", "true"),
648-
] {
651+
for (k, v) in DEFAULT_REPO_CONFIG.iter() {
649652
Command::new("ostree")
650653
.args(["config", "--repo", "ostree/repo", "set", k, v])
651654
.cwd_dir(rootfs_dir.try_clone()?)
@@ -657,6 +660,19 @@ async fn initialize_ostree_root(state: &State, root_setup: &RootSetup) -> Result
657660
ostree::Sysroot::new(Some(&gio::File::for_path(path)))
658661
};
659662
sysroot.load(cancellable)?;
663+
let repo = &sysroot.repo();
664+
665+
let repo_verity_state = ostree_ext::fsverity::is_verity_enabled(&repo)?;
666+
let prepare_root_composefs = state
667+
.prepareroot_config
668+
.get("composefs.enabled")
669+
.map(|v| ComposefsState::from_str(&v))
670+
.transpose()?
671+
.unwrap_or(ComposefsState::default());
672+
if prepare_root_composefs.requires_fsverity() || repo_verity_state.desired == Tristate::Enabled
673+
{
674+
ostree_ext::fsverity::ensure_verity(repo).await?;
675+
}
660676

661677
let stateroot_exists = rootfs_dir.try_exists(format!("ostree/deploy/{stateroot}"))?;
662678
ensure!(

0 commit comments

Comments
 (0)