bootc_lib/bootc_composefs/
selinux.rs

1use anyhow::{Context, Result};
2use bootc_initramfs_setup::mount_composefs_image;
3use bootc_mount::tempmount::TempMount;
4use cap_std_ext::cap_std::{ambient_authority, fs::Dir};
5use cap_std_ext::dirext::CapStdExtDirExt;
6use fn_error_context::context;
7
8use crate::bootc_composefs::status::ComposefsCmdline;
9use crate::lsm::selinux_enabled;
10use crate::store::Storage;
11
12const SELINUX_CONFIG_PATH: &str = "etc/selinux/config";
13const SELINUX_TYPE: &str = "SELINUXTYPE=";
14const POLICY_FILE_PREFIX: &str = "policy.";
15
16/// Find the highest versioned policy file in the given directory
17fn find_latest_policy_file(policy_dir: &Dir) -> Result<String> {
18    let mut highest_policy_version = -1;
19    let mut latest_policy_name = None;
20
21    for entry in policy_dir
22        .entries_utf8()
23        .context("Getting policy dir entries")?
24    {
25        let entry = entry?;
26
27        if !entry.file_type()?.is_file() {
28            // We don't want symlinks, another directory etc
29            continue;
30        }
31
32        let filename = entry.file_name()?;
33
34        match filename.strip_prefix(POLICY_FILE_PREFIX) {
35            Some(version) => {
36                let v_int = version
37                    .parse::<i32>()
38                    .with_context(|| anyhow::anyhow!("Parsing {version} as int"))?;
39
40                if v_int < highest_policy_version {
41                    continue;
42                }
43
44                highest_policy_version = v_int;
45                latest_policy_name = Some(filename.to_string());
46            }
47
48            None => continue,
49        };
50    }
51
52    latest_policy_name.ok_or_else(|| anyhow::anyhow!("Failed to get latest SELinux policy"))
53}
54
55/// Compute SHA256 hash of a policy file
56fn compute_policy_file_hash(deployment_root: &Dir, full_path: &str) -> Result<String> {
57    let mut file = deployment_root
58        .open(full_path)
59        .context("Opening policy file")?;
60    let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())?;
61    std::io::copy(&mut file, &mut hasher)?;
62
63    let hash = hex::encode(hasher.finish().context("Computing hash")?);
64    Ok(hash)
65}
66
67#[context("Getting SELinux policy for deployment {depl_id}")]
68fn get_selinux_policy_for_deployment(
69    storage: &Storage,
70    booted_cmdline: &ComposefsCmdline,
71    depl_id: &str,
72) -> Result<Option<String>> {
73    let sysroot_fd = storage.physical_root.reopen_as_ownedfd()?;
74
75    // Booted deployment. We want to get the policy from "/etc" as it might have been modified
76    let (deployment_root, _mount_guard) = if *booted_cmdline.digest == *depl_id {
77        (Dir::open_ambient_dir("/", ambient_authority())?, None)
78    } else {
79        let composefs_fd = mount_composefs_image(&sysroot_fd, depl_id, false)?;
80        let erofs_tmp_mnt = TempMount::mount_fd(&composefs_fd)?;
81
82        (erofs_tmp_mnt.fd.try_clone()?, Some(erofs_tmp_mnt))
83    };
84
85    if !deployment_root.exists(SELINUX_CONFIG_PATH) {
86        return Ok(None);
87    }
88
89    let selinux_config = deployment_root
90        .read_to_string(SELINUX_CONFIG_PATH)
91        .context("Reading selinux config")?;
92
93    let type_ = selinux_config
94        .lines()
95        .find(|l| l.starts_with(SELINUX_TYPE))
96        .ok_or_else(|| anyhow::anyhow!("Falied to find SELINUXTYPE"))?
97        .split("=")
98        .nth(1)
99        .ok_or_else(|| anyhow::anyhow!("Failed to parse SELINUXTYPE"))?
100        .trim();
101
102    let policy_dir_path = format!("etc/selinux/{type_}/policy");
103
104    let policy_dir = deployment_root
105        .open_dir(&policy_dir_path)
106        .context("Opening selinux policy dir")?;
107
108    let policy_name = find_latest_policy_file(&policy_dir)?;
109
110    let full_path = format!("{policy_dir_path}/{policy_name}");
111
112    let hash = compute_policy_file_hash(&deployment_root, &full_path)?;
113
114    Ok(Some(hash))
115}
116
117#[context("Checking SELinux policy compatibility")]
118pub(crate) fn are_selinux_policies_compatible(
119    storage: &Storage,
120    booted_cmdline: &ComposefsCmdline,
121    depl_id: &str,
122) -> Result<bool> {
123    if !selinux_enabled()? {
124        return Ok(true);
125    }
126
127    let booted_policy_hash =
128        get_selinux_policy_for_deployment(storage, booted_cmdline, &booted_cmdline.digest)?;
129
130    let depl_policy_hash = get_selinux_policy_for_deployment(storage, booted_cmdline, depl_id)?;
131
132    let sl_policy_match = match (booted_policy_hash, depl_policy_hash) {
133        // both have policies, compare them
134        (Some(booted_csum), Some(target_csum)) => booted_csum == target_csum,
135        // one depl has policy while the other doesn't
136        (Some(_), None) | (None, Some(_)) => false,
137        // no policy in either
138        (None, None) => true,
139    };
140
141    if !sl_policy_match {
142        tracing::debug!("Soft rebooting not allowed due to differing SELinux policies");
143    }
144
145    Ok(sl_policy_match)
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use cap_std_ext::cap_std::ambient_authority;
152    use cap_std_ext::dirext::CapStdExtDirExt;
153
154    #[test]
155    fn test_find_latest_policy_file() -> Result<()> {
156        let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?;
157
158        // Create policy files with different versions
159        tempdir.atomic_write("policy.30", "policy content 30")?;
160        tempdir.atomic_write("policy.31", "policy content 31")?;
161        tempdir.atomic_write("policy.29", "policy content 29")?;
162        tempdir.atomic_write("not_policy.32", "not a policy file")?;
163        tempdir.atomic_write("other_policy.txt", "invalid policy file")?;
164
165        let result = find_latest_policy_file(&tempdir)?;
166        assert_eq!(result, "policy.31");
167
168        Ok(())
169    }
170
171    #[test]
172    fn test_find_latest_policy_file_with_single_file() -> Result<()> {
173        let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?;
174
175        tempdir.atomic_write("policy.25", "single policy file")?;
176
177        let result = find_latest_policy_file(&tempdir)?;
178        assert_eq!(result, "policy.25");
179
180        Ok(())
181    }
182
183    #[test]
184    fn test_find_latest_policy_file_no_policy_files() {
185        let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority()).unwrap();
186
187        tempdir
188            .atomic_write("not_policy.txt", "not a policy file")
189            .unwrap();
190        tempdir.atomic_write("other.txt", "invalid format").unwrap();
191
192        let result = find_latest_policy_file(&tempdir);
193        assert!(result.is_err());
194        assert!(
195            result
196                .unwrap_err()
197                .to_string()
198                .contains("Failed to get latest SELinux policy")
199        );
200    }
201
202    #[test]
203    fn test_find_latest_policy_file_invalid_version() {
204        let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority()).unwrap();
205
206        tempdir
207            .atomic_write("policy.abc", "invalid version")
208            .unwrap();
209
210        let result = find_latest_policy_file(&tempdir);
211        assert!(result.is_err());
212        assert!(
213            result
214                .unwrap_err()
215                .to_string()
216                .contains("Parsing abc as int")
217        );
218    }
219
220    #[test]
221    fn test_find_latest_policy_file_negative_version() -> Result<()> {
222        let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?;
223
224        tempdir.atomic_write("policy.5", "positive version")?;
225        tempdir.atomic_write("policy.-1", "negative version")?;
226
227        let result = find_latest_policy_file(&tempdir)?;
228        assert_eq!(result, "policy.5");
229
230        Ok(())
231    }
232
233    #[test]
234    fn test_find_latest_policy_file_skips_directories() -> Result<()> {
235        let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?;
236
237        tempdir.create_dir("policy.99")?; // This should be skipped
238        tempdir.atomic_write("policy.5", "actual policy file")?;
239
240        let result = find_latest_policy_file(&tempdir)?;
241        assert_eq!(result, "policy.5");
242
243        Ok(())
244    }
245
246    #[test]
247    fn test_compute_policy_file_hash() -> Result<()> {
248        let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?;
249
250        let test_content = "test policy content for hashing";
251        tempdir.atomic_write("test_policy.30", test_content)?;
252
253        let hash = compute_policy_file_hash(&tempdir, "test_policy.30")?;
254
255        // Verify the hash is a valid SHA256 hash (64 hex characters)
256        assert_eq!(hash.len(), 64);
257        assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
258
259        // Verify consistent hashing
260        let hash2 = compute_policy_file_hash(&tempdir, "test_policy.30")?;
261        assert_eq!(hash, hash2);
262
263        Ok(())
264    }
265
266    #[test]
267    fn test_compute_policy_file_hash_different_content() -> Result<()> {
268        let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?;
269
270        tempdir.atomic_write("policy1.30", "content 1")?;
271        tempdir.atomic_write("policy2.30", "content 2")?;
272
273        let hash1 = compute_policy_file_hash(&tempdir, "policy1.30")?;
274        let hash2 = compute_policy_file_hash(&tempdir, "policy2.30")?;
275
276        assert_ne!(hash1, hash2);
277
278        Ok(())
279    }
280
281    #[test]
282    fn test_compute_policy_file_hash_nonexistent_file() {
283        let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority()).unwrap();
284
285        let result = compute_policy_file_hash(&tempdir, "nonexistent.30");
286        assert!(result.is_err());
287        assert!(
288            result
289                .unwrap_err()
290                .to_string()
291                .contains("Opening policy file")
292        );
293    }
294
295    #[test]
296    fn test_compute_policy_file_hash_empty_file() -> Result<()> {
297        let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?;
298
299        tempdir.atomic_write("empty_policy.30", "")?;
300
301        let hash = compute_policy_file_hash(&tempdir, "empty_policy.30")?;
302
303        // Should produce a valid hash even for empty file
304        assert_eq!(hash.len(), 64);
305        assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
306
307        // SHA256 of empty string
308        assert_eq!(
309            hash,
310            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
311        );
312
313        Ok(())
314    }
315}