1mod digest;
8mod hashvalue;
9mod ioctl;
10
11pub use digest::FsVerityHasher;
12
13use std::{
14 fs::File,
15 io::{Error, Seek},
16 os::{
17 fd::{AsFd, BorrowedFd, OwnedFd},
18 unix::fs::PermissionsExt,
19 },
20};
21
22use rustix::fs::{open, openat, Mode, OFlags};
23use thiserror::Error;
24
25pub use hashvalue::{FsVerityHashValue, Sha256HashValue, Sha512HashValue};
26
27use crate::util::proc_self_fd;
28
29#[derive(Error, Debug)] pub enum MeasureVerityError {
32 #[error("{0}")]
34 Io(#[from] Error),
35 #[error("fs-verity is not enabled on file")]
37 VerityMissing,
38 #[error("fs-verity is not supported by filesystem")]
40 FilesystemNotSupported,
41 #[error("Expected algorithm {expected}, found {found}")]
43 InvalidDigestAlgorithm {
44 expected: u16,
46 found: u16,
48 },
49 #[error("Expected digest size {expected}")]
51 InvalidDigestSize {
52 expected: u16,
54 },
55}
56
57#[derive(Error, Debug)]
59pub enum EnableVerityError {
60 #[error("{0}")]
62 Io(#[from] Error),
63 #[error("Filesystem does not support fs-verity")]
65 FilesystemNotSupported,
66 #[error("fs-verity is already enabled on file")]
68 AlreadyEnabled,
69 #[error("File is opened for writing")]
71 FileOpenedForWrite,
72}
73
74#[derive(Error, Debug)]
76pub enum CompareVerityError {
77 #[error("failed to read verity")]
79 Measure(#[from] MeasureVerityError),
80 #[error("Expected digest {expected} but found {found}")]
82 DigestMismatch {
83 expected: String,
85 found: String,
87 },
88}
89
90pub fn compute_verity<H: FsVerityHashValue>(data: &[u8]) -> H {
105 digest::FsVerityHasher::<H, 12>::hash(data)
106}
107
108pub fn enable_verity_raw<H: FsVerityHashValue>(fd: impl AsFd) -> Result<(), EnableVerityError> {
118 ioctl::fs_ioc_enable_verity::<H>(fd)
119}
120
121pub fn enable_verity_with_retry<H: FsVerityHashValue>(
144 fd: impl AsFd,
145) -> Result<(), EnableVerityError> {
146 let mut attempt = 1;
147 loop {
148 match enable_verity_raw::<H>(&fd) {
149 Err(EnableVerityError::FileOpenedForWrite) if attempt < 3 => {
150 std::thread::sleep(std::time::Duration::from_millis(1));
151 attempt += 1;
152 }
153 other => return other,
154 }
155 }
156}
157
158pub fn enable_verity_maybe_copy<H: FsVerityHashValue>(
180 dirfd: impl AsFd,
181 fd: BorrowedFd,
182) -> Result<Option<OwnedFd>, EnableVerityError> {
183 match enable_verity_with_retry::<H>(&fd) {
184 Ok(()) => Ok(None),
185 Err(EnableVerityError::FileOpenedForWrite) => {
186 let fd = enable_verity_on_copy::<H>(dirfd, fd)?;
187 Ok(Some(fd))
188 }
189 Err(other) => Err(other),
190 }
191}
192
193fn enable_verity_on_copy<H: FsVerityHashValue>(
197 dirfd: impl AsFd,
198 fd: BorrowedFd,
199) -> Result<OwnedFd, EnableVerityError> {
200 let fd = fd.try_clone_to_owned().map_err(EnableVerityError::Io)?;
201 let mut fd = File::from(fd);
202 let mode = fd.metadata()?.permissions().mode();
203
204 loop {
205 fd.rewind().map_err(EnableVerityError::Io)?;
206
207 let mut new_rw_fd = File::from(
208 openat(
209 &dirfd,
210 ".",
211 OFlags::CLOEXEC | OFlags::RDWR | OFlags::TMPFILE,
212 mode.into(),
213 )
214 .map_err(|e| EnableVerityError::Io(e.into()))?,
215 );
216
217 std::io::copy(&mut fd, &mut new_rw_fd)?;
218 let new_ro_fd = open(
219 proc_self_fd(&new_rw_fd),
220 OFlags::RDONLY | OFlags::CLOEXEC,
221 Mode::empty(),
222 )
223 .map_err(|e| EnableVerityError::Io(e.into()))?;
224 drop(new_rw_fd);
225 if enable_verity_with_retry::<H>(&new_ro_fd).is_ok() {
226 return Ok(new_ro_fd);
227 }
228 }
229}
230
231pub fn measure_verity<H: FsVerityHashValue>(fd: impl AsFd) -> Result<H, MeasureVerityError> {
252 ioctl::fs_ioc_measure_verity(fd)
253}
254
255pub fn measure_verity_opt<H: FsVerityHashValue>(
263 fd: impl AsFd,
264) -> Result<Option<H>, MeasureVerityError> {
265 match ioctl::fs_ioc_measure_verity(fd) {
266 Ok(result) => Ok(Some(result)),
267 Err(MeasureVerityError::VerityMissing | MeasureVerityError::FilesystemNotSupported) => {
268 Ok(None)
269 }
270 Err(other) => Err(other),
271 }
272}
273
274pub fn ensure_verity_equal(
285 fd: impl AsFd,
286 expected: &impl FsVerityHashValue,
287) -> Result<(), CompareVerityError> {
288 let found = measure_verity(fd)?;
289 if expected == &found {
290 Ok(())
291 } else {
292 Err(CompareVerityError::DigestMismatch {
293 expected: expected.to_hex(),
294 found: found.to_hex(),
295 })
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use std::{collections::BTreeSet, io::Write, os::unix::process::CommandExt, time::Duration};
302
303 use once_cell::sync::Lazy;
304 use rand::Rng;
305 use rustix::{
306 fd::OwnedFd,
307 fs::{open, Mode, OFlags},
308 };
309 use tempfile::{tempfile_in, TempDir};
310 use tokio::{task::JoinSet, time::Instant};
311
312 use crate::{test::tempdir, util::proc_self_fd};
313
314 use super::*;
315
316 static TEMPDIR: Lazy<TempDir> = Lazy::new(tempdir);
317 static TD_FD: Lazy<File> = Lazy::new(|| File::open(TEMPDIR.path()).unwrap());
318
319 fn tempfile() -> File {
320 tempfile_in(TEMPDIR.path()).unwrap()
321 }
322
323 fn rdonly_file_with(data: &[u8]) -> OwnedFd {
324 let mut file = tempfile();
325 file.write_all(data).unwrap();
326 file.sync_data().unwrap();
327 let fd = open(
328 proc_self_fd(&file),
329 OFlags::RDONLY | OFlags::CLOEXEC,
330 Mode::empty(),
331 )
332 .unwrap();
333 drop(file); fd
335 }
336
337 fn empty_file_in_tmpdir(flags: OFlags, mode: Mode) -> (tempfile::TempDir, OwnedFd) {
338 let tmpdir = tempdir();
339 let path = tmpdir.path().join("empty");
340 let fd = open(path, OFlags::CLOEXEC | OFlags::CREATE | flags, mode).unwrap();
341 (tmpdir, fd)
342 }
343
344 #[test]
345 fn test_verity_missing() {
346 let tf = rdonly_file_with(b"");
347
348 assert!(matches!(
349 measure_verity::<Sha256HashValue>(&tf).unwrap_err(),
350 MeasureVerityError::VerityMissing
351 ));
352
353 assert!(measure_verity_opt::<Sha256HashValue>(&tf)
354 .unwrap()
355 .is_none());
356
357 assert!(matches!(
358 ensure_verity_equal(&tf, &Sha256HashValue::EMPTY).unwrap_err(),
359 CompareVerityError::Measure(MeasureVerityError::VerityMissing)
360 ));
361 }
362
363 #[test]
364 fn test_verity_simple() {
365 let tf = rdonly_file_with(b"hello world");
366
367 let tf = enable_verity_maybe_copy::<Sha256HashValue>(&*TD_FD, tf.as_fd())
369 .unwrap()
370 .unwrap_or(tf);
371
372 assert!(matches!(
374 enable_verity_maybe_copy::<Sha256HashValue>(&*TD_FD, tf.as_fd()).unwrap_err(),
375 EnableVerityError::AlreadyEnabled
376 ));
377
378 assert_eq!(
379 measure_verity::<Sha256HashValue>(&tf).unwrap().to_hex(),
380 "1e2eaa4202d750a41174ee454970b92c1bc2f925b1e35076d8c7d5f56362ba64"
381 );
382
383 assert_eq!(
384 measure_verity_opt::<Sha256HashValue>(&tf)
385 .unwrap()
386 .unwrap()
387 .to_hex(),
388 "1e2eaa4202d750a41174ee454970b92c1bc2f925b1e35076d8c7d5f56362ba64"
389 );
390
391 ensure_verity_equal(
392 &tf,
393 &Sha256HashValue::from_hex(
394 "1e2eaa4202d750a41174ee454970b92c1bc2f925b1e35076d8c7d5f56362ba64",
395 )
396 .unwrap(),
397 )
398 .unwrap();
399
400 let Err(CompareVerityError::DigestMismatch { expected, found }) = ensure_verity_equal(
401 &tf,
402 &Sha256HashValue::from_hex(
403 "1e2eaa4202d750a41174ee454970b92c1bc2f925b1e35076d8c7000000000000",
404 )
405 .unwrap(),
406 ) else {
407 panic!("Didn't fail with expected error");
408 };
409 assert_eq!(
410 expected,
411 "1e2eaa4202d750a41174ee454970b92c1bc2f925b1e35076d8c7000000000000"
412 );
413 assert_eq!(
414 found,
415 "1e2eaa4202d750a41174ee454970b92c1bc2f925b1e35076d8c7d5f56362ba64"
416 );
417 }
418
419 #[allow(unsafe_code)]
420 #[tokio::test]
421 async fn test_verity_forking() {
422 const DELAY_MIN: u64 = 0;
423 const DELAY_MAX: u64 = 10;
424 const SUCCESS_LIMIT: u32 = 100;
426 const TIMEOUT: Duration = Duration::from_secs(10);
428 let start = Instant::now();
429
430 let cpus = std::thread::available_parallelism().unwrap();
431 let threads = cpus.get() >> 1;
433 assert!(threads >= 1);
434 eprintln!("using {threads} threads");
435 let mut txs = vec![];
436 let mut jhs = vec![];
437
438 for _ in 0..threads {
439 let (tx, rx) = std::sync::mpsc::channel();
440 let jh = std::thread::spawn(move || {
441 let mut rng = rand::rng();
442
443 loop {
444 if rx.try_recv().is_ok() {
445 break;
446 }
447
448 let delay = rng.random_range(DELAY_MIN..=DELAY_MAX);
449 let delay = Duration::from_millis(delay);
450 unsafe {
451 std::process::Command::new("true")
452 .pre_exec(move || {
453 std::thread::sleep(delay);
454 Ok(())
455 })
456 .status()
457 .unwrap();
458 }
459 }
460 });
461
462 txs.push(tx);
463 jhs.push(jh);
464 }
465
466 let raw_verity_enabler = async move {
467 let mut successes = 0;
468 let mut failures = 0;
469
470 loop {
471 if tokio::time::Instant::now().duration_since(start) > TIMEOUT {
472 break;
473 }
474 if successes == SUCCESS_LIMIT {
475 break;
476 }
477
478 let r = tokio::task::spawn_blocking(move || {
479 let ro_fd = rdonly_file_with(b"hello world");
480 enable_verity_raw::<Sha256HashValue>(&ro_fd)
481 })
482 .await
483 .unwrap();
484 if r.is_ok() {
485 successes += 1;
486 } else {
487 failures += 1;
488 }
489 }
490
491 (successes, failures)
492 };
493
494 let retry_verity_enabler = async move {
495 let mut successes = 0;
496 let mut failures = 0;
497
498 loop {
499 if tokio::time::Instant::now().duration_since(start) > TIMEOUT {
500 break;
501 }
502 if successes == SUCCESS_LIMIT {
503 break;
504 }
505
506 let r = tokio::task::spawn_blocking(move || {
507 let ro_fd = rdonly_file_with(b"hello world");
508 enable_verity_with_retry::<Sha256HashValue>(&ro_fd)
509 })
510 .await
511 .unwrap();
512 if r.is_ok() {
513 successes += 1;
514 } else {
515 failures += 1;
516 }
517 }
518
519 (successes, failures)
520 };
521
522 let copy_verity_enabler = async move {
523 let mut orig = 0;
524 let mut copy = 0;
525
526 loop {
527 if tokio::time::Instant::now().duration_since(start) > TIMEOUT {
528 break;
529 }
530 if orig + copy == SUCCESS_LIMIT {
531 break;
532 }
533
534 let is_copy = tokio::task::spawn_blocking(|| {
535 let ro_fd = rdonly_file_with(b"Hello world");
536 enable_verity_maybe_copy::<Sha256HashValue>(&*TD_FD, ro_fd.as_fd())
537 .unwrap()
538 .is_some()
539 })
540 .await
541 .unwrap();
542
543 if is_copy {
544 copy += 1;
545 } else {
546 orig += 1;
547 }
548 }
549
550 (orig, copy)
551 };
552
553 let ts = tokio::time::Instant::now();
554
555 let mut set = JoinSet::new();
556 set.spawn(async move {
557 let (successes, failures) = raw_verity_enabler.await;
558 let elapsed = ts.elapsed().as_millis();
559 eprintln!("raw verity enabled ({successes} attempts succeeded, {failures} attempts failed) in {elapsed}ms");
560 });
561 set.spawn(async move {
562 let (successes, failures) = retry_verity_enabler.await;
563 let elapsed = ts.elapsed().as_millis();
564 eprintln!("retry verity enabled ({successes} attempts succeeded, {failures} attempts failed) in {elapsed}ms");
565 });
566 set.spawn(async move {
567 let (orig, copy) = copy_verity_enabler.await;
568 assert!(orig > 0 || copy > 0);
569 let elapsed = ts.elapsed().as_millis();
570 eprintln!("copy verity enabled ({orig} original, {copy} copies) in {elapsed}ms");
571 });
572
573 while let Some(res) = set.join_next().await {
574 res.unwrap();
575 }
576
577 txs.into_iter().for_each(|tx| tx.send(()).unwrap());
578 jhs.into_iter().for_each(|jh| jh.join().unwrap());
579 }
580
581 #[test_with::path(/dev/shm)]
582 #[test]
583 fn test_verity_error_noverity() {
584 let tf = tempfile_in("/dev/shm").unwrap();
585
586 assert!(matches!(
587 enable_verity_with_retry::<Sha256HashValue>(&tf).unwrap_err(),
588 EnableVerityError::FilesystemNotSupported
589 ));
590
591 assert!(matches!(
592 measure_verity::<Sha256HashValue>(&tf).unwrap_err(),
593 MeasureVerityError::FilesystemNotSupported
594 ));
595
596 assert!(measure_verity_opt::<Sha256HashValue>(&tf)
597 .unwrap()
598 .is_none());
599
600 assert!(matches!(
601 ensure_verity_equal(&tf, &Sha256HashValue::EMPTY).unwrap_err(),
602 CompareVerityError::Measure(MeasureVerityError::FilesystemNotSupported)
603 ));
604 }
605
606 #[test]
607 fn test_verity_wrongdigest_sha512_sha256() {
608 let tf = rdonly_file_with(b"hello world");
609
610 let tf = enable_verity_maybe_copy::<Sha512HashValue>(&*TD_FD, tf.as_fd())
612 .unwrap()
613 .unwrap_or(tf);
614
615 assert!(matches!(
616 measure_verity::<Sha256HashValue>(&tf).unwrap_err(),
617 MeasureVerityError::InvalidDigestSize { .. }
618 ));
619
620 assert!(matches!(
621 measure_verity_opt::<Sha256HashValue>(&tf).unwrap_err(),
622 MeasureVerityError::InvalidDigestSize { .. }
623 ));
624
625 assert!(matches!(
626 ensure_verity_equal(&tf, &Sha256HashValue::EMPTY).unwrap_err(),
627 CompareVerityError::Measure(MeasureVerityError::InvalidDigestSize { .. })
628 ));
629 }
630
631 #[test]
632 fn test_verity_wrongdigest_sha256_sha512() {
633 let tf = rdonly_file_with(b"hello world");
634
635 let tf = enable_verity_maybe_copy::<Sha256HashValue>(&*TD_FD, tf.as_fd())
637 .unwrap()
638 .unwrap_or(tf);
639
640 assert!(matches!(
641 measure_verity::<Sha512HashValue>(&tf).unwrap_err(),
642 MeasureVerityError::InvalidDigestAlgorithm { .. }
643 ));
644
645 assert!(matches!(
646 measure_verity_opt::<Sha512HashValue>(&tf).unwrap_err(),
647 MeasureVerityError::InvalidDigestAlgorithm { .. }
648 ));
649
650 assert!(matches!(
651 ensure_verity_equal(&tf, &Sha512HashValue::EMPTY).unwrap_err(),
652 CompareVerityError::Measure(MeasureVerityError::InvalidDigestAlgorithm { .. })
653 ));
654 }
655
656 #[test]
657 fn crosscheck_interesting_cases() {
658 let mut cases = BTreeSet::new();
664 for arity in [32, 64] {
665 for layer4 in [0 ] {
666 for layer3 in [-1, 0, 1] {
668 for layer2 in [-1, 0, 1] {
669 for layer1 in [-1, 0, 1] {
670 for layer0 in [-1, 0, 1] {
671 let candidate = layer4 * (arity * arity * arity * arity)
672 + layer3 * (arity * arity * arity)
673 + layer2 * (arity * arity)
674 + layer1 * arity
675 + layer0;
676 if let Ok(size) = usize::try_from(candidate) {
677 cases.insert(size);
678 }
679 }
680 }
681 }
682 }
683 }
684 }
685
686 fn assert_kernel_equal<H: FsVerityHashValue>(data: &[u8], expected: H) {
687 let fd = rdonly_file_with(data);
688 let fd = enable_verity_maybe_copy::<H>(&*TD_FD, fd.as_fd())
689 .unwrap()
690 .unwrap_or(fd);
691 ensure_verity_equal(&fd, &expected).unwrap();
692 }
693
694 for size in cases {
695 let data = vec![0x5a; size];
697 assert_kernel_equal(&data, compute_verity::<Sha256HashValue>(&data));
698 assert_kernel_equal(&data, compute_verity::<Sha512HashValue>(&data));
699 }
700 }
701
702 #[test]
703 fn test_enable_verity_maybe_copy_without_copy() {
704 let (tempdir, fd) = empty_file_in_tmpdir(OFlags::RDONLY, 0o644.into());
708 let tempdir_fd = File::open(tempdir.path()).unwrap();
709 let fd = enable_verity_maybe_copy::<Sha256HashValue>(&tempdir_fd, fd.as_fd()).unwrap();
710 assert!(fd.is_none());
711 }
712
713 #[test]
714 fn test_enable_verity_maybe_copy_with_copy() {
715 let (tempdir, fd) = empty_file_in_tmpdir(OFlags::RDWR, 0o644.into());
719 let tempdir_fd = File::open(tempdir.path()).unwrap();
720 let mut fd = File::from(fd);
721 let _ = fd.write(b"hello world").unwrap();
722 let fd = enable_verity_maybe_copy::<Sha256HashValue>(&tempdir_fd, fd.as_fd())
723 .unwrap()
724 .unwrap();
725
726 assert!(ensure_verity_equal(
728 fd,
729 &Sha256HashValue::from_hex(
730 "1e2eaa4202d750a41174ee454970b92c1bc2f925b1e35076d8c7d5f56362ba64",
731 )
732 .unwrap(),
733 )
734 .is_ok());
735 }
736}