mod digest;
mod hashvalue;
mod ioctl;
pub use digest::FsVerityHasher;
use std::{
fs::File,
io::{BufRead, BufReader, Seek},
os::{
fd::{AsFd, BorrowedFd, OwnedFd},
unix::fs::PermissionsExt,
},
};
use rustix::fs::{Mode, OFlags, open, openat};
use thiserror::Error;
pub use hashvalue::{
Algorithm, AlgorithmParseError, DEFAULT_LG_BLOCKSIZE, FsVerityHashValue, Sha256HashValue,
Sha512HashValue,
};
pub use ioctl::{EnableVerityError, MeasureVerityError};
use crate::util::proc_self_fd;
#[derive(Error, Debug)]
pub enum CompareVerityError {
#[error("failed to read verity")]
Measure(#[from] MeasureVerityError),
#[error("Expected digest {expected} but found {found}")]
DigestMismatch {
expected: String,
found: String,
},
}
pub fn compute_verity<H: FsVerityHashValue>(data: &[u8]) -> H {
digest::FsVerityHasher::<H, 12>::hash(data)
}
pub fn enable_verity_raw<H: FsVerityHashValue>(fd: impl AsFd) -> Result<(), EnableVerityError> {
ioctl::fs_ioc_enable_verity::<H>(fd)
}
pub fn enable_verity_with_retry<H: FsVerityHashValue>(
fd: impl AsFd,
) -> Result<(), EnableVerityError> {
let mut attempt = 1;
loop {
match enable_verity_raw::<H>(&fd) {
Err(EnableVerityError::FileOpenedForWrite) if attempt < 3 => {
std::thread::sleep(std::time::Duration::from_millis(1));
attempt += 1;
}
other => return other,
}
}
}
pub fn enable_verity_maybe_copy<H: FsVerityHashValue>(
dirfd: impl AsFd,
fd: BorrowedFd,
) -> Result<Option<OwnedFd>, EnableVerityError> {
match enable_verity_with_retry::<H>(&fd) {
Ok(()) => Ok(None),
Err(EnableVerityError::FileOpenedForWrite) => {
let fd = enable_verity_on_copy::<H>(dirfd, fd)?;
Ok(Some(fd))
}
Err(other) => Err(other),
}
}
fn enable_verity_on_copy<H: FsVerityHashValue>(
dirfd: impl AsFd,
fd: BorrowedFd,
) -> Result<OwnedFd, EnableVerityError> {
let fd = fd.try_clone_to_owned().map_err(EnableVerityError::Io)?;
let mut fd = File::from(fd);
let mode = fd.metadata()?.permissions().mode();
loop {
fd.rewind().map_err(EnableVerityError::Io)?;
let mut new_rw_fd = File::from(
openat(
&dirfd,
".",
OFlags::CLOEXEC | OFlags::RDWR | OFlags::TMPFILE,
mode.into(),
)
.map_err(|e| EnableVerityError::Io(e.into()))?,
);
std::io::copy(&mut fd, &mut new_rw_fd)?;
let new_ro_fd = open(
proc_self_fd(&new_rw_fd),
OFlags::RDONLY | OFlags::CLOEXEC,
Mode::empty(),
)
.map_err(|e| EnableVerityError::Io(e.into()))?;
drop(new_rw_fd);
if enable_verity_with_retry::<H>(&new_ro_fd).is_ok() {
return Ok(new_ro_fd);
}
}
}
pub fn measure_verity<H: FsVerityHashValue>(fd: impl AsFd) -> Result<H, MeasureVerityError> {
ioctl::fs_ioc_measure_verity(fd)
}
pub fn measure_verity_opt<H: FsVerityHashValue>(
fd: impl AsFd,
) -> Result<Option<H>, MeasureVerityError> {
match ioctl::fs_ioc_measure_verity(fd) {
Ok(result) => Ok(Some(result)),
Err(MeasureVerityError::VerityMissing | MeasureVerityError::FilesystemNotSupported) => {
Ok(None)
}
Err(other) => Err(other),
}
}
pub fn measure_verity_with_fallback<H: FsVerityHashValue>(
fd: impl AsFd + std::io::Read,
) -> Result<H, MeasureVerityError> {
match measure_verity_opt(&fd) {
Ok(Some(digest)) => return Ok(digest),
Ok(None) => {}
Err(e) => return Err(e),
}
let mut hasher = FsVerityHasher::<H>::new();
let mut reader = BufReader::with_capacity(FsVerityHasher::<H>::BLOCK_SIZE * 2, fd);
loop {
let buf = reader.fill_buf().map_err(MeasureVerityError::Io)?;
if buf.is_empty() {
break;
}
let chunk = &buf[..buf.len().min(FsVerityHasher::<H>::BLOCK_SIZE)];
hasher.add_block(chunk);
let n = chunk.len();
reader.consume(n);
}
Ok(hasher.digest())
}
pub(crate) fn has_verity(fd: impl AsFd, algorithm: Algorithm) -> Result<bool, MeasureVerityError> {
match algorithm {
Algorithm::Sha256 { .. } => Ok(measure_verity_opt::<Sha256HashValue>(fd)?.is_some()),
Algorithm::Sha512 { .. } => Ok(measure_verity_opt::<Sha512HashValue>(fd)?.is_some()),
}
}
pub fn ensure_verity_equal(
fd: impl AsFd,
expected: &impl FsVerityHashValue,
) -> Result<(), CompareVerityError> {
let found = measure_verity(fd)?;
if expected == &found {
Ok(())
} else {
Err(CompareVerityError::DigestMismatch {
expected: expected.to_hex(),
found: found.to_hex(),
})
}
}
#[cfg(test)]
mod tests {
use std::{collections::BTreeSet, io::Write, time::Duration};
use composefs_ioctls::test_utils_pub::CommandExt;
use once_cell::sync::Lazy;
use rand::RngExt;
use rustix::{
fd::OwnedFd,
fs::{Mode, OFlags, open},
};
use tempfile::{TempDir, tempfile_in};
use tokio::{task::JoinSet, time::Instant};
use crate::{test::tempdir, util::proc_self_fd};
use super::*;
static TEMPDIR: Lazy<TempDir> = Lazy::new(tempdir);
static TD_FD: Lazy<File> = Lazy::new(|| File::open(TEMPDIR.path()).unwrap());
fn tempfile() -> File {
tempfile_in(TEMPDIR.path()).unwrap()
}
fn rdonly_file_with(data: &[u8]) -> OwnedFd {
let mut file = tempfile();
file.write_all(data).unwrap();
file.sync_data().unwrap();
let fd = open(
proc_self_fd(&file),
OFlags::RDONLY | OFlags::CLOEXEC,
Mode::empty(),
)
.unwrap();
drop(file); fd
}
fn empty_file_in_tmpdir(flags: OFlags, mode: Mode) -> (tempfile::TempDir, OwnedFd) {
let tmpdir = tempdir();
let path = tmpdir.path().join("empty");
let fd = open(path, OFlags::CLOEXEC | OFlags::CREATE | flags, mode).unwrap();
(tmpdir, fd)
}
#[test]
fn test_verity_missing() {
let tf = rdonly_file_with(b"");
assert!(matches!(
measure_verity::<Sha256HashValue>(&tf).unwrap_err(),
MeasureVerityError::VerityMissing
));
assert!(
measure_verity_opt::<Sha256HashValue>(&tf)
.unwrap()
.is_none()
);
assert!(matches!(
ensure_verity_equal(&tf, &Sha256HashValue::EMPTY).unwrap_err(),
CompareVerityError::Measure(MeasureVerityError::VerityMissing)
));
}
#[test]
fn test_verity_simple() {
let tf = rdonly_file_with(b"hello world");
let tf = enable_verity_maybe_copy::<Sha256HashValue>(&*TD_FD, tf.as_fd())
.unwrap()
.unwrap_or(tf);
assert!(matches!(
enable_verity_maybe_copy::<Sha256HashValue>(&*TD_FD, tf.as_fd()).unwrap_err(),
EnableVerityError::AlreadyEnabled
));
assert_eq!(
measure_verity::<Sha256HashValue>(&tf).unwrap().to_hex(),
"1e2eaa4202d750a41174ee454970b92c1bc2f925b1e35076d8c7d5f56362ba64"
);
assert_eq!(
measure_verity_opt::<Sha256HashValue>(&tf)
.unwrap()
.unwrap()
.to_hex(),
"1e2eaa4202d750a41174ee454970b92c1bc2f925b1e35076d8c7d5f56362ba64"
);
ensure_verity_equal(
&tf,
&Sha256HashValue::from_hex(
"1e2eaa4202d750a41174ee454970b92c1bc2f925b1e35076d8c7d5f56362ba64",
)
.unwrap(),
)
.unwrap();
let Err(CompareVerityError::DigestMismatch { expected, found }) = ensure_verity_equal(
&tf,
&Sha256HashValue::from_hex(
"1e2eaa4202d750a41174ee454970b92c1bc2f925b1e35076d8c7000000000000",
)
.unwrap(),
) else {
panic!("Didn't fail with expected error");
};
assert_eq!(
expected,
"1e2eaa4202d750a41174ee454970b92c1bc2f925b1e35076d8c7000000000000"
);
assert_eq!(
found,
"1e2eaa4202d750a41174ee454970b92c1bc2f925b1e35076d8c7d5f56362ba64"
);
}
#[tokio::test]
async fn test_verity_forking() {
const DELAY_MIN: u64 = 0;
const DELAY_MAX: u64 = 10;
const SUCCESS_LIMIT: u32 = 100;
const TIMEOUT: Duration = Duration::from_secs(10);
let start = Instant::now();
let cpus = std::thread::available_parallelism().unwrap();
let threads = cpus.get() >> 1;
assert!(threads >= 1);
eprintln!("using {threads} threads");
let mut txs = vec![];
let mut jhs = vec![];
for _ in 0..threads {
let (tx, rx) = std::sync::mpsc::channel();
let jh = std::thread::spawn(move || {
let mut rng = rand::rng();
loop {
if rx.try_recv().is_ok() {
break;
}
let delay = rng.random_range(DELAY_MIN..=DELAY_MAX);
let delay = Duration::from_millis(delay);
std::process::Command::new("true")
.pre_exec_sleep(delay)
.status()
.unwrap();
}
});
txs.push(tx);
jhs.push(jh);
}
let raw_verity_enabler = async move {
let mut successes = 0;
let mut failures = 0;
loop {
if tokio::time::Instant::now().duration_since(start) > TIMEOUT {
break;
}
if successes == SUCCESS_LIMIT {
break;
}
let r = tokio::task::spawn_blocking(move || {
let ro_fd = rdonly_file_with(b"hello world");
enable_verity_raw::<Sha256HashValue>(&ro_fd)
})
.await
.unwrap();
if r.is_ok() {
successes += 1;
} else {
failures += 1;
}
}
(successes, failures)
};
let retry_verity_enabler = async move {
let mut successes = 0;
let mut failures = 0;
loop {
if tokio::time::Instant::now().duration_since(start) > TIMEOUT {
break;
}
if successes == SUCCESS_LIMIT {
break;
}
let r = tokio::task::spawn_blocking(move || {
let ro_fd = rdonly_file_with(b"hello world");
enable_verity_with_retry::<Sha256HashValue>(&ro_fd)
})
.await
.unwrap();
if r.is_ok() {
successes += 1;
} else {
failures += 1;
}
}
(successes, failures)
};
let copy_verity_enabler = async move {
let mut orig = 0;
let mut copy = 0;
loop {
if tokio::time::Instant::now().duration_since(start) > TIMEOUT {
break;
}
if orig + copy == SUCCESS_LIMIT {
break;
}
let is_copy = tokio::task::spawn_blocking(|| {
let ro_fd = rdonly_file_with(b"Hello world");
enable_verity_maybe_copy::<Sha256HashValue>(&*TD_FD, ro_fd.as_fd())
.unwrap()
.is_some()
})
.await
.unwrap();
if is_copy {
copy += 1;
} else {
orig += 1;
}
}
(orig, copy)
};
let ts = tokio::time::Instant::now();
let mut set = JoinSet::new();
set.spawn(async move {
let (successes, failures) = raw_verity_enabler.await;
let elapsed = ts.elapsed().as_millis();
eprintln!("raw verity enabled ({successes} attempts succeeded, {failures} attempts failed) in {elapsed}ms");
});
set.spawn(async move {
let (successes, failures) = retry_verity_enabler.await;
let elapsed = ts.elapsed().as_millis();
eprintln!("retry verity enabled ({successes} attempts succeeded, {failures} attempts failed) in {elapsed}ms");
});
set.spawn(async move {
let (orig, copy) = copy_verity_enabler.await;
assert!(orig > 0 || copy > 0);
let elapsed = ts.elapsed().as_millis();
eprintln!("copy verity enabled ({orig} original, {copy} copies) in {elapsed}ms");
});
while let Some(res) = set.join_next().await {
res.unwrap();
}
txs.into_iter().for_each(|tx| tx.send(()).unwrap());
jhs.into_iter().for_each(|jh| jh.join().unwrap());
}
#[test_with::path(/dev/shm)]
#[test]
fn test_verity_error_noverity() {
let tf = tempfile_in("/dev/shm").unwrap();
assert!(matches!(
enable_verity_with_retry::<Sha256HashValue>(&tf).unwrap_err(),
EnableVerityError::FilesystemNotSupported
));
assert!(matches!(
measure_verity::<Sha256HashValue>(&tf).unwrap_err(),
MeasureVerityError::FilesystemNotSupported
));
assert!(
measure_verity_opt::<Sha256HashValue>(&tf)
.unwrap()
.is_none()
);
assert!(matches!(
ensure_verity_equal(&tf, &Sha256HashValue::EMPTY).unwrap_err(),
CompareVerityError::Measure(MeasureVerityError::FilesystemNotSupported)
));
}
#[test]
fn test_verity_wrongdigest_sha512_sha256() {
let tf = rdonly_file_with(b"hello world");
let tf = enable_verity_maybe_copy::<Sha512HashValue>(&*TD_FD, tf.as_fd())
.unwrap()
.unwrap_or(tf);
assert!(matches!(
measure_verity::<Sha256HashValue>(&tf).unwrap_err(),
MeasureVerityError::InvalidDigestSize { .. }
));
assert!(matches!(
measure_verity_opt::<Sha256HashValue>(&tf).unwrap_err(),
MeasureVerityError::InvalidDigestSize { .. }
));
assert!(matches!(
ensure_verity_equal(&tf, &Sha256HashValue::EMPTY).unwrap_err(),
CompareVerityError::Measure(MeasureVerityError::InvalidDigestSize { .. })
));
}
#[test]
fn test_verity_wrongdigest_sha256_sha512() {
let tf = rdonly_file_with(b"hello world");
let tf = enable_verity_maybe_copy::<Sha256HashValue>(&*TD_FD, tf.as_fd())
.unwrap()
.unwrap_or(tf);
assert!(matches!(
measure_verity::<Sha512HashValue>(&tf).unwrap_err(),
MeasureVerityError::InvalidDigestAlgorithm { .. }
));
assert!(matches!(
measure_verity_opt::<Sha512HashValue>(&tf).unwrap_err(),
MeasureVerityError::InvalidDigestAlgorithm { .. }
));
assert!(matches!(
ensure_verity_equal(&tf, &Sha512HashValue::EMPTY).unwrap_err(),
CompareVerityError::Measure(MeasureVerityError::InvalidDigestAlgorithm { .. })
));
}
#[test]
fn crosscheck_interesting_cases() {
let mut cases = BTreeSet::new();
for arity in [32, 64] {
for layer4 in [ 0 ] {
for layer3 in [-1, 0, 1] {
for layer2 in [-1, 0, 1] {
for layer1 in [-1, 0, 1] {
for layer0 in [-1, 0, 1] {
let candidate = layer4 * (arity * arity * arity * arity)
+ layer3 * (arity * arity * arity)
+ layer2 * (arity * arity)
+ layer1 * arity
+ layer0;
if let Ok(size) = usize::try_from(candidate) {
cases.insert(size);
}
}
}
}
}
}
}
fn assert_kernel_equal<H: FsVerityHashValue>(data: &[u8], expected: H) {
let fd = rdonly_file_with(data);
let fd = enable_verity_maybe_copy::<H>(&*TD_FD, fd.as_fd())
.unwrap()
.unwrap_or(fd);
ensure_verity_equal(&fd, &expected).unwrap();
}
for size in cases {
let data = vec![0x5a; size];
assert_kernel_equal(&data, compute_verity::<Sha256HashValue>(&data));
assert_kernel_equal(&data, compute_verity::<Sha512HashValue>(&data));
}
}
#[test]
fn test_enable_verity_maybe_copy_without_copy() {
let (tempdir, fd) = empty_file_in_tmpdir(OFlags::RDONLY, 0o644.into());
let tempdir_fd = File::open(tempdir.path()).unwrap();
let fd = enable_verity_maybe_copy::<Sha256HashValue>(&tempdir_fd, fd.as_fd()).unwrap();
assert!(fd.is_none());
}
#[test]
fn test_enable_verity_maybe_copy_with_copy() {
let (tempdir, fd) = empty_file_in_tmpdir(OFlags::RDWR, 0o644.into());
let tempdir_fd = File::open(tempdir.path()).unwrap();
let mut fd = File::from(fd);
let _ = fd.write(b"hello world").unwrap();
let fd = enable_verity_maybe_copy::<Sha256HashValue>(&tempdir_fd, fd.as_fd())
.unwrap()
.unwrap();
assert!(
ensure_verity_equal(
fd,
&Sha256HashValue::from_hex(
"1e2eaa4202d750a41174ee454970b92c1bc2f925b1e35076d8c7d5f56362ba64",
)
.unwrap(),
)
.is_ok()
);
}
}