composefs_boot/
selabel.rs

1//! SELinux security context labeling for filesystem trees.
2//!
3//! This module implements SELinux policy parsing and file labeling functionality.
4//! It reads SELinux policy files (file_contexts, file_contexts.subs, etc.) and applies
5//! appropriate security.selinux extended attributes to filesystem nodes. The implementation
6//! uses regex automata for efficient pattern matching against file paths and types.
7
8use std::{
9    collections::HashMap,
10    ffi::{OsStr, OsString},
11    fs::File,
12    io::{BufRead, BufReader, Read},
13    os::unix::ffi::OsStrExt,
14    path::{Path, PathBuf},
15};
16
17use anyhow::{bail, ensure, Context, Result};
18use regex_automata::{hybrid::dfa, util::syntax, Anchored, Input};
19
20use composefs::{
21    fsverity::FsVerityHashValue,
22    repository::Repository,
23    tree::{Directory, FileSystem, Inode, Leaf, LeafContent, RegularFile, Stat},
24};
25
26/* We build the entire SELinux policy into a single "lazy DFA" such that:
27 *
28 *  - the input string is the filename plus a single character representing the type of the file,
29 *    using the 'file type' codes listed in selabel_file(5): 'b', 'c', 'd', 'p', 'l', 's', and '-'
30 *
31 *  - the output pattern ID is the index of the selected context
32 *
33 * The 'subs' mapping is handled as a hash table.  We consult it each time we enter a directory and
34 * perform the substitution a single time at that point instead of doing it for each contained
35 * file.
36 *
37 * We could maybe add a string table to deduplicate contexts to save memory (as they are often
38 * repeated).  It's not an order-of-magnitude kind of gain, though, and it would increase code
39 * complexity, and slightly decrease efficiency.
40 *
41 * Note: we are not 100% compatible with PCRE here, so it's theoretically possible that someone
42 * could write a policy that we can't properly handle...
43 */
44
45fn process_subs_file(file: impl Read, aliases: &mut HashMap<OsString, OsString>) -> Result<()> {
46    // r"\s*([^\s]+)\s+([^\s]+)\s*";
47    for (line_nr, item) in BufReader::new(file).lines().enumerate() {
48        let line = item?;
49        let mut parts = line.split_whitespace();
50        let alias = match parts.next() {
51            None => continue, // empty line or line with only whitespace
52            Some(comment) if comment.starts_with("#") => continue,
53            Some(alias) => alias,
54        };
55        let Some(original) = parts.next() else {
56            bail!("{line_nr}: missing original path");
57        };
58        ensure!(parts.next().is_none(), "{line_nr}: trailing data");
59
60        aliases.insert(OsString::from(alias), OsString::from(original));
61    }
62    Ok(())
63}
64
65fn process_spec_file(
66    file: impl Read,
67    regexps: &mut Vec<String>,
68    contexts: &mut Vec<String>,
69) -> Result<()> {
70    // r"\s*([^\s]+)\s+(?:-([-bcdpls])\s+)?([^\s]+)\s*";
71    for (line_nr, item) in BufReader::new(file).lines().enumerate() {
72        let line = item?;
73
74        let mut parts = line.split_whitespace();
75        let regex = match parts.next() {
76            None => continue, // empty line or line with only whitespace
77            Some(comment) if comment.starts_with("#") => continue,
78            Some(regex) => regex,
79        };
80
81        /* TODO: https://github.com/rust-lang/rust/issues/51114
82         *  match parts.next() {
83         *      Some(opt) if let Some(ifmt) = opt.strip_prefix("-") => ...
84         */
85        let Some(next) = parts.next() else {
86            bail!("{line_nr}: missing separator after regex");
87        };
88        if let Some(ifmt) = next.strip_prefix("-") {
89            ensure!(
90                ["b", "c", "d", "p", "l", "s", "-"].contains(&ifmt),
91                "{line_nr}: invalid type code -{ifmt}"
92            );
93            let Some(context) = parts.next() else {
94                bail!("{line_nr}: missing context field");
95            };
96            regexps.push(format!("^({regex}){ifmt}$"));
97            contexts.push(context.to_string());
98        } else {
99            let context = next;
100            regexps.push(format!("^({regex}).$"));
101            contexts.push(context.to_string());
102        }
103        ensure!(parts.next().is_none(), "{line_nr}: trailing data");
104    }
105
106    Ok(())
107}
108
109struct Policy {
110    aliases: HashMap<OsString, OsString>,
111    dfa: dfa::DFA,
112    cache: dfa::Cache,
113    contexts: Vec<String>,
114}
115
116/// Open a file in the composefs store, handling inline vs external files.
117pub fn openat<'a, H: FsVerityHashValue>(
118    dir: &'a Directory<H>,
119    filename: impl AsRef<OsStr>,
120    repo: &Repository<H>,
121) -> Result<Option<Box<dyn Read + 'a>>> {
122    match dir.get_file_opt(filename.as_ref())? {
123        Some(file) => match file {
124            RegularFile::Inline(data) => Ok(Some(Box::new(&**data))),
125            RegularFile::External(id, ..) => Ok(Some(Box::new(File::from(repo.open_object(id)?)))),
126        },
127        None => Ok(None),
128    }
129}
130
131impl Policy {
132    pub fn build<H: FsVerityHashValue>(dir: &Directory<H>, repo: &Repository<H>) -> Result<Self> {
133        let mut aliases = HashMap::new();
134        let mut regexps = vec![];
135        let mut contexts = vec![];
136
137        for suffix in ["", ".local", ".homedirs"] {
138            if let Some(file) = openat(dir, format!("file_contexts{suffix}"), repo)? {
139                process_spec_file(file, &mut regexps, &mut contexts)
140                    .with_context(|| format!("SELinux spec file file_contexts{suffix}"))?;
141            } else if suffix.is_empty() {
142                bail!("SELinux policy is missing mandatory file_contexts file");
143            }
144        }
145
146        for suffix in [".subs", ".subs_dist"] {
147            if let Some(file) = openat(dir, format!("file_contexts{suffix}"), repo)? {
148                process_subs_file(file, &mut aliases)
149                    .with_context(|| format!("SELinux subs file file_contexts{suffix}"))?;
150            }
151        }
152
153        // The DFA matches the first-found.  We want to match the last-found.
154        regexps.reverse();
155        contexts.reverse();
156
157        let mut builder = dfa::Builder::new();
158        builder.syntax(
159            syntax::Config::new()
160                .unicode(false)
161                .utf8(false)
162                .line_terminator(0),
163        );
164        builder.configure(
165            dfa::Config::new()
166                .cache_capacity(10_000_000)
167                .skip_cache_capacity_check(true),
168        );
169        let dfa = builder.build_many(&regexps)?;
170        let cache = dfa.create_cache();
171
172        Ok(Policy {
173            aliases,
174            dfa,
175            cache,
176            contexts,
177        })
178    }
179
180    pub fn check_aliased(&self, filename: &OsStr) -> Option<&OsStr> {
181        self.aliases.get(filename).map(|x| x.as_os_str())
182    }
183
184    // mut because it touches the cache
185    pub fn lookup(&mut self, filename: &OsStr, ifmt: u8) -> Option<&str> {
186        let key = &[filename.as_bytes(), &[ifmt]].concat();
187        let input = Input::new(&key).anchored(Anchored::Yes);
188
189        match self
190            .dfa
191            .try_search_fwd(&mut self.cache, &input)
192            .expect("regex troubles")
193        {
194            Some(halfmatch) => match self.contexts[halfmatch.pattern()].as_str() {
195                "<<none>>" => None,
196                ctx => Some(ctx),
197            },
198            None => None,
199        }
200    }
201}
202
203fn relabel(stat: &Stat, path: &Path, ifmt: u8, policy: &mut Policy) {
204    let security_selinux = OsStr::new("security.selinux"); // no literal syntax for this yet
205    let mut xattrs = stat.xattrs.borrow_mut();
206
207    if let Some(label) = policy.lookup(path.as_os_str(), ifmt) {
208        xattrs.insert(Box::from(security_selinux), Box::from(label.as_bytes()));
209    } else {
210        xattrs.remove(security_selinux);
211    }
212}
213
214fn relabel_leaf<H: FsVerityHashValue>(leaf: &Leaf<H>, path: &Path, policy: &mut Policy) {
215    let ifmt = match leaf.content {
216        LeafContent::Regular(..) => b'-',
217        LeafContent::Fifo => b'p', // NB: 'pipe', not 'fifo'
218        LeafContent::Socket => b's',
219        LeafContent::Symlink(..) => b'l',
220        LeafContent::BlockDevice(..) => b'b',
221        LeafContent::CharacterDevice(..) => b'c',
222    };
223    relabel(&leaf.stat, path, ifmt, policy);
224}
225
226fn relabel_inode<H: FsVerityHashValue>(inode: &Inode<H>, path: &mut PathBuf, policy: &mut Policy) {
227    match inode {
228        Inode::Directory(ref dir) => relabel_dir(dir, path, policy),
229        Inode::Leaf(ref leaf) => relabel_leaf(leaf, path, policy),
230    }
231}
232
233fn relabel_dir<H: FsVerityHashValue>(dir: &Directory<H>, path: &mut PathBuf, policy: &mut Policy) {
234    relabel(&dir.stat, path, b'd', policy);
235
236    for (name, inode) in dir.sorted_entries() {
237        path.push(name);
238        match policy.check_aliased(path.as_os_str()) {
239            Some(original) => relabel_inode(inode, &mut PathBuf::from(original), policy),
240            None => relabel_inode(inode, path, policy),
241        }
242        path.pop();
243    }
244}
245
246fn parse_config(file: impl Read) -> Result<Option<String>> {
247    for line in BufReader::new(file).lines() {
248        if let Some((key, value)) = line?.split_once('=') {
249            // this might be a comment, but then key will start with '#'
250            if key.trim().eq_ignore_ascii_case("SELINUXTYPE") {
251                return Ok(Some(value.trim().to_string()));
252            }
253        }
254    }
255    Ok(None)
256}
257
258/// Applies SELinux security contexts to all files in a filesystem tree.
259///
260/// Reads the SELinux policy from /etc/selinux/config and corresponding policy files,
261/// then labels all filesystem nodes with appropriate security.selinux extended attributes.
262///
263/// # Arguments
264///
265/// * `fs` - The filesystem to label
266/// * `repo` - The composefs repository
267///
268/// # Returns
269///
270/// Ok(()) if labeling succeeds or if no SELinux policy is found
271pub fn selabel<H: FsVerityHashValue>(fs: &mut FileSystem<H>, repo: &Repository<H>) -> Result<()> {
272    // if /etc/selinux/config doesn't exist then it's not an error
273    let Some(etc_selinux) = fs.root.get_directory_opt("etc/selinux".as_ref())? else {
274        return Ok(());
275    };
276
277    let Some(etc_selinux_config) = openat(etc_selinux, "config", repo)? else {
278        return Ok(());
279    };
280
281    let Some(policy) = parse_config(etc_selinux_config)? else {
282        return Ok(());
283    };
284
285    let dir = etc_selinux
286        .get_directory(policy.as_ref())?
287        .get_directory("contexts/files".as_ref())?;
288
289    let mut policy = Policy::build(dir, repo)?;
290    let mut path = PathBuf::from("/");
291    relabel_dir(&fs.root, &mut path, &mut policy);
292    Ok(())
293}