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 =
80            mount_composefs_image(&sysroot_fd, depl_id, booted_cmdline.allow_missing_fsverity)?;
81        let erofs_tmp_mnt = TempMount::mount_fd(&composefs_fd)?;
82
83        (erofs_tmp_mnt.fd.try_clone()?, Some(erofs_tmp_mnt))
84    };
85
86    if !deployment_root.exists(SELINUX_CONFIG_PATH) {
87        return Ok(None);
88    }
89
90    let selinux_config = deployment_root
91        .read_to_string(SELINUX_CONFIG_PATH)
92        .context("Reading selinux config")?;
93
94    let type_ = selinux_config
95        .lines()
96        .find(|l| l.starts_with(SELINUX_TYPE))
97        .ok_or_else(|| anyhow::anyhow!("Falied to find SELINUXTYPE"))?
98        .split("=")
99        .nth(1)
100        .ok_or_else(|| anyhow::anyhow!("Failed to parse SELINUXTYPE"))?
101        .trim();
102
103    let policy_dir_path = format!("etc/selinux/{type_}/policy");
104
105    let policy_dir = deployment_root
106        .open_dir(&policy_dir_path)
107        .context("Opening selinux policy dir")?;
108
109    let policy_name = find_latest_policy_file(&policy_dir)?;
110
111    let full_path = format!("{policy_dir_path}/{policy_name}");
112
113    let hash = compute_policy_file_hash(&deployment_root, &full_path)?;
114
115    Ok(Some(hash))
116}
117
118#[context("Checking SELinux policy compatibility")]
119pub(crate) fn are_selinux_policies_compatible(
120    storage: &Storage,
121    booted_cmdline: &ComposefsCmdline,
122    depl_id: &str,
123) -> Result<bool> {
124    if !selinux_enabled()? {
125        return Ok(true);
126    }
127
128    let booted_policy_hash =
129        get_selinux_policy_for_deployment(storage, booted_cmdline, &booted_cmdline.digest)?;
130
131    let depl_policy_hash = get_selinux_policy_for_deployment(storage, booted_cmdline, depl_id)?;
132
133    let sl_policy_match = match (booted_policy_hash, depl_policy_hash) {
134        // both have policies, compare them
135        (Some(booted_csum), Some(target_csum)) => booted_csum == target_csum,
136        // one depl has policy while the other doesn't
137        (Some(_), None) | (None, Some(_)) => false,
138        // no policy in either
139        (None, None) => true,
140    };
141
142    if !sl_policy_match {
143        tracing::debug!("Soft rebooting not allowed due to differing SELinux policies");
144    }
145
146    Ok(sl_policy_match)
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use cap_std_ext::cap_std::ambient_authority;
153    use cap_std_ext::dirext::CapStdExtDirExt;
154
155    #[test]
156    fn test_find_latest_policy_file() -> Result<()> {
157        let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?;
158
159        // Create policy files with different versions
160        tempdir.atomic_write("policy.30", "policy content 30")?;
161        tempdir.atomic_write("policy.31", "policy content 31")?;
162        tempdir.atomic_write("policy.29", "policy content 29")?;
163        tempdir.atomic_write("not_policy.32", "not a policy file")?;
164        tempdir.atomic_write("other_policy.txt", "invalid policy file")?;
165
166        let result = find_latest_policy_file(&tempdir)?;
167        assert_eq!(result, "policy.31");
168
169        Ok(())
170    }
171
172    #[test]
173    fn test_find_latest_policy_file_with_single_file() -> Result<()> {
174        let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?;
175
176        tempdir.atomic_write("policy.25", "single policy file")?;
177
178        let result = find_latest_policy_file(&tempdir)?;
179        assert_eq!(result, "policy.25");
180
181        Ok(())
182    }
183
184    #[test]
185    fn test_find_latest_policy_file_no_policy_files() {
186        let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority()).unwrap();
187
188        tempdir
189            .atomic_write("not_policy.txt", "not a policy file")
190            .unwrap();
191        tempdir.atomic_write("other.txt", "invalid format").unwrap();
192
193        let result = find_latest_policy_file(&tempdir);
194        assert!(result.is_err());
195        assert!(
196            result
197                .unwrap_err()
198                .to_string()
199                .contains("Failed to get latest SELinux policy")
200        );
201    }
202
203    #[test]
204    fn test_find_latest_policy_file_invalid_version() {
205        let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority()).unwrap();
206
207        tempdir
208            .atomic_write("policy.abc", "invalid version")
209            .unwrap();
210
211        let result = find_latest_policy_file(&tempdir);
212        assert!(result.is_err());
213        assert!(
214            result
215                .unwrap_err()
216                .to_string()
217                .contains("Parsing abc as int")
218        );
219    }
220
221    #[test]
222    fn test_find_latest_policy_file_negative_version() -> Result<()> {
223        let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?;
224
225        tempdir.atomic_write("policy.5", "positive version")?;
226        tempdir.atomic_write("policy.-1", "negative version")?;
227
228        let result = find_latest_policy_file(&tempdir)?;
229        assert_eq!(result, "policy.5");
230
231        Ok(())
232    }
233
234    #[test]
235    fn test_find_latest_policy_file_skips_directories() -> Result<()> {
236        let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?;
237
238        tempdir.create_dir("policy.99")?; // This should be skipped
239        tempdir.atomic_write("policy.5", "actual policy file")?;
240
241        let result = find_latest_policy_file(&tempdir)?;
242        assert_eq!(result, "policy.5");
243
244        Ok(())
245    }
246
247    #[test]
248    fn test_compute_policy_file_hash() -> Result<()> {
249        let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?;
250
251        let test_content = "test policy content for hashing";
252        tempdir.atomic_write("test_policy.30", test_content)?;
253
254        let hash = compute_policy_file_hash(&tempdir, "test_policy.30")?;
255
256        // Verify the hash is a valid SHA256 hash (64 hex characters)
257        assert_eq!(hash.len(), 64);
258        assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
259
260        // Verify consistent hashing
261        let hash2 = compute_policy_file_hash(&tempdir, "test_policy.30")?;
262        assert_eq!(hash, hash2);
263
264        Ok(())
265    }
266
267    #[test]
268    fn test_compute_policy_file_hash_different_content() -> Result<()> {
269        let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?;
270
271        tempdir.atomic_write("policy1.30", "content 1")?;
272        tempdir.atomic_write("policy2.30", "content 2")?;
273
274        let hash1 = compute_policy_file_hash(&tempdir, "policy1.30")?;
275        let hash2 = compute_policy_file_hash(&tempdir, "policy2.30")?;
276
277        assert_ne!(hash1, hash2);
278
279        Ok(())
280    }
281
282    #[test]
283    fn test_compute_policy_file_hash_nonexistent_file() {
284        let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority()).unwrap();
285
286        let result = compute_policy_file_hash(&tempdir, "nonexistent.30");
287        assert!(result.is_err());
288        assert!(
289            result
290                .unwrap_err()
291                .to_string()
292                .contains("Opening policy file")
293        );
294    }
295
296    #[test]
297    fn test_compute_policy_file_hash_empty_file() -> Result<()> {
298        let tempdir = cap_std_ext::cap_tempfile::tempdir(ambient_authority())?;
299
300        tempdir.atomic_write("empty_policy.30", "")?;
301
302        let hash = compute_policy_file_hash(&tempdir, "empty_policy.30")?;
303
304        // Should produce a valid hash even for empty file
305        assert_eq!(hash.len(), 64);
306        assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
307
308        // SHA256 of empty string
309        assert_eq!(
310            hash,
311            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
312        );
313
314        Ok(())
315    }
316}