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