Skip to main content

fxcp_core/
fsverity.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2// Copyright (C) 2025 Joel Wirāmu Pauling <aenertia@aenertia.net>
3//! fs-verity integration for tamper-evident snapshot sealing.
4//!
5//! Provides `FS_IOC_ENABLE_VERITY` ioctl wrapper to seal files with a
6//! kernel-enforced Merkle tree. Once sealed, any modification to the file
7//! causes reads to return EIO. Works on XFS (kernel 6.12+) and btrfs.
8
9use std::os::unix::io::AsRawFd;
10use std::path::Path;
11use tracing::{debug, info, warn};
12
13/// fs-verity hash algorithms (from linux/fsverity.h)
14pub const FS_VERITY_HASH_ALG_SHA256: u32 = 1;
15pub const FS_VERITY_HASH_ALG_SHA512: u32 = 2;
16
17/// FS_IOC_ENABLE_VERITY ioctl number: _IOW('f', 0x85, struct fsverity_enable_arg)
18const FS_IOC_ENABLE_VERITY: libc::c_ulong = 0x40806685;
19
20/// FS_IOC_MEASURE_VERITY ioctl number: _IOWR('f', 0x86, struct fsverity_digest)
21const FS_IOC_MEASURE_VERITY: libc::c_ulong = 0xC0046686;
22
23/// Errors from fs-verity operations.
24#[derive(Debug, thiserror::Error)]
25pub enum FsVerityError {
26    #[error("fs-verity not supported on this filesystem")]
27    NotSupported,
28    #[error("file has open writable descriptors")]
29    FileInUse,
30    #[error("I/O error: {0}")]
31    Io(#[from] std::io::Error),
32}
33
34/// Statistics from a batch seal operation.
35#[derive(Debug, Default)]
36pub struct SealStats {
37    pub sealed: u32,
38    pub skipped: u32,
39    pub already_sealed: u32,
40    pub errors: u32,
41}
42
43/// fsverity_enable_arg for FS_IOC_ENABLE_VERITY ioctl (linux/fsverity.h)
44#[repr(C)]
45struct FsVerityEnableArg {
46    version: u32,
47    hash_algorithm: u32,
48    block_size: u32,
49    salt_size: u32,
50    salt_ptr: u64,
51    sig_size: u32,
52    __reserved1: u32,
53    sig_ptr: u64,
54    __reserved2: [u64; 11],
55}
56
57/// fsverity_digest for FS_IOC_MEASURE_VERITY ioctl
58#[repr(C)]
59struct FsVerityDigest {
60    digest_algorithm: u16,
61    digest_size: u16,
62    digest: [u8; 64], // large enough for SHA-512
63}
64
65/// Seal a file with fs-verity.
66///
67/// The file must not have any open writable file descriptors.
68/// After sealing, any modification to the file's data will cause
69/// subsequent reads to return EIO.
70///
71/// Returns `Ok(())` on success, or if the file is already sealed (idempotent).
72pub fn seal_with_fsverity(path: &Path, algorithm: u32) -> Result<(), FsVerityError> {
73    let file = std::fs::OpenOptions::new()
74        .read(true)
75        .open(path)
76        .map_err(FsVerityError::Io)?;
77
78    let arg = FsVerityEnableArg {
79        version: 1,
80        hash_algorithm: algorithm,
81        block_size: 4096,
82        salt_size: 0,
83        salt_ptr: 0,
84        sig_size: 0,
85        __reserved1: 0,
86        sig_ptr: 0,
87        __reserved2: [0; 11],
88    };
89
90    // SAFETY: file is a valid open fd. arg is a repr(C) FsVerityEnableArg
91    // struct matching the kernel's fsverity_enable_arg layout.
92    let ret = unsafe {
93        libc::ioctl(file.as_raw_fd(), FS_IOC_ENABLE_VERITY, &arg)
94    };
95
96    if ret != 0 {
97        let err = std::io::Error::last_os_error();
98        match err.raw_os_error() {
99            Some(libc::EOPNOTSUPP) | Some(libc::ENOTTY) => {
100                return Err(FsVerityError::NotSupported);
101            }
102            Some(libc::EBUSY) | Some(libc::ETXTBSY) => {
103                return Err(FsVerityError::FileInUse);
104            }
105            Some(libc::EEXIST) => {
106                // Already sealed  --  idempotent success
107                debug!("fs-verity already enabled: {:?}", path);
108                return Ok(());
109            }
110            _ => return Err(FsVerityError::Io(err)),
111        }
112    }
113
114    debug!("fs-verity sealed: {:?}", path);
115    Ok(())
116}
117
118/// Check if a file has fs-verity enabled.
119pub fn is_verity_enabled(path: &Path) -> bool {
120    let file = match std::fs::OpenOptions::new().read(true).open(path) {
121        Ok(f) => f,
122        Err(_) => return false,
123    };
124
125    let mut digest = FsVerityDigest {
126        digest_algorithm: 0,
127        digest_size: 64,
128        digest: [0u8; 64],
129    };
130
131    // SAFETY: file is a valid open fd. digest is a repr(C) FsVerityDigest
132    // struct with digest_size set to the buffer capacity (64 bytes).
133    let ret = unsafe {
134        libc::ioctl(file.as_raw_fd(), FS_IOC_MEASURE_VERITY, &mut digest)
135    };
136
137    ret == 0
138}
139
140/// Seal all files in a directory tree with fs-verity.
141///
142/// Walks the directory recursively and calls `seal_with_fsverity` on each
143/// regular file. Stops trying if the filesystem doesn't support verity
144/// (detected on the first file).
145pub fn seal_snapshot_directory(dir: &Path, algorithm: u32) -> Result<SealStats, FsVerityError> {
146    let mut stats = SealStats::default();
147    let mut verity_supported = true;
148
149    for entry in walkdir::WalkDir::new(dir)
150        .into_iter()
151        .filter_map(|e| e.ok())
152        .filter(|e| e.file_type().is_file())
153    {
154        if !verity_supported {
155            stats.skipped += 1;
156            continue;
157        }
158
159        match seal_with_fsverity(entry.path(), algorithm) {
160            Ok(()) => stats.sealed += 1,
161            Err(FsVerityError::NotSupported) => {
162                info!("fs-verity not supported on {:?}, skipping remaining files", dir);
163                verity_supported = false;
164                stats.skipped += 1;
165            }
166            Err(FsVerityError::FileInUse) => {
167                warn!("fs-verity: file in use, skipping: {:?}", entry.path());
168                stats.skipped += 1;
169            }
170            Err(e) => {
171                warn!("fs-verity seal error on {:?}: {}", entry.path(), e);
172                stats.errors += 1;
173            }
174        }
175    }
176
177    if stats.sealed > 0 {
178        info!(
179            "fs-verity sealed {} files in {:?} ({} skipped, {} errors)",
180            stats.sealed, dir, stats.skipped, stats.errors
181        );
182    }
183
184    Ok(stats)
185}
186
187/// Parse a hash algorithm name to its kernel constant.
188pub fn parse_algorithm(name: &str) -> Option<u32> {
189    match name.to_lowercase().as_str() {
190        "sha256" | "sha-256" => Some(FS_VERITY_HASH_ALG_SHA256),
191        "sha512" | "sha-512" => Some(FS_VERITY_HASH_ALG_SHA512),
192        _ => None,
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
199    use super::*;
200
201    #[test]
202    fn test_parse_algorithm() {
203        assert_eq!(parse_algorithm("sha256"), Some(FS_VERITY_HASH_ALG_SHA256));
204        assert_eq!(parse_algorithm("SHA-256"), Some(FS_VERITY_HASH_ALG_SHA256));
205        assert_eq!(parse_algorithm("sha512"), Some(FS_VERITY_HASH_ALG_SHA512));
206        assert_eq!(parse_algorithm("invalid"), None);
207    }
208
209    #[test]
210    fn test_seal_on_tmpfs_returns_not_supported() {
211        let dir = tempfile::tempdir().unwrap();
212        let file = dir.path().join("test.dat");
213        std::fs::write(&file, b"test content").unwrap();
214
215        match seal_with_fsverity(&file, FS_VERITY_HASH_ALG_SHA256) {
216            Err(FsVerityError::NotSupported) => {} // expected on tmpfs
217            Ok(()) => {} // might work if tmpdir is on a verity-capable fs
218            Err(e) => panic!("Unexpected error: {:?}", e),
219        }
220    }
221
222    #[test]
223    fn test_is_verity_enabled_on_normal_file() {
224        let dir = tempfile::tempdir().unwrap();
225        let file = dir.path().join("test.dat");
226        std::fs::write(&file, b"test content").unwrap();
227
228        // Normal file on tmpfs should not have verity
229        assert!(!is_verity_enabled(&file));
230    }
231
232    #[test]
233    fn test_seal_snapshot_directory_empty() {
234        let dir = tempfile::tempdir().unwrap();
235        let stats = seal_snapshot_directory(dir.path(), FS_VERITY_HASH_ALG_SHA256).unwrap();
236        assert_eq!(stats.sealed, 0);
237        assert_eq!(stats.errors, 0);
238    }
239}