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