Skip to main content

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 a hybrid approach: a regex-automata lazy DFA for patterns the Rust regex
7//! engine supports, with pcre2 fallback for PCRE2-specific features (e.g. lookarounds).
8
9use std::{
10    collections::HashMap,
11    ffi::{OsStr, OsString},
12    fs::File,
13    io::{BufRead, BufReader, Cursor, Read},
14    os::unix::ffi::OsStrExt,
15    path::{Path, PathBuf},
16};
17
18use anyhow::{Context, Result, bail, ensure};
19use fn_error_context::context;
20use pcre2::bytes::Regex;
21use regex_automata::{Anchored, Input, hybrid::dfa, util::syntax};
22use rustix::{
23    fd::AsFd,
24    fs::{Mode, OFlags, openat},
25    io::Errno,
26};
27
28use composefs::{
29    fsverity::FsVerityHashValue,
30    repository::Repository,
31    tree::{Directory, DirectoryRef, FileSystem, Inode, Leaf, LeafContent, RegularFile, Stat},
32};
33
34/// The SELinux security context extended attribute name.
35///
36/// This xattr stores the SELinux label for a file (e.g., `system_u:object_r:bin_t:s0`).
37/// When reading from mounted filesystems, this xattr often contains build-host labels
38/// that should be stripped or regenerated based on the target system's policy.
39pub const XATTR_SECURITY_SELINUX: &str = "security.selinux";
40
41#[context("Processing SELinux substitutions file")]
42fn process_subs_file(file: impl Read, aliases: &mut HashMap<OsString, OsString>) -> Result<()> {
43    // r"\s*([^\s]+)\s+([^\s]+)\s*";
44    for (line_nr, item) in BufReader::new(file).lines().enumerate() {
45        let line = item?;
46        let mut parts = line.split_whitespace();
47        let alias = match parts.next() {
48            None => continue, // empty line or line with only whitespace
49            Some(comment) if comment.starts_with("#") => continue,
50            Some(alias) => alias,
51        };
52        let Some(original) = parts.next() else {
53            bail!("{line_nr}: missing original path");
54        };
55        ensure!(parts.next().is_none(), "{line_nr}: trailing data");
56
57        aliases.insert(OsString::from(alias), OsString::from(original));
58    }
59    Ok(())
60}
61
62fn process_spec_file(
63    file: impl Read,
64    regexps: &mut Vec<String>,
65    contexts: &mut Vec<String>,
66) -> Result<()> {
67    // r"\s*([^\s]+)\s+(?:-([-bcdpls])\s+)?([^\s]+)\s*";
68    for (line_nr, item) in BufReader::new(file).lines().enumerate() {
69        let line = item?;
70
71        let mut parts = line.split_whitespace();
72        let regex = match parts.next() {
73            None => continue, // empty line or line with only whitespace
74            Some(comment) if comment.starts_with("#") => continue,
75            Some(regex) => regex,
76        };
77
78        /* TODO: https://github.com/rust-lang/rust/issues/51114
79         *  match parts.next() {
80         *      Some(opt) if let Some(ifmt) = opt.strip_prefix("-") => ...
81         */
82        let Some(next) = parts.next() else {
83            bail!("{line_nr}: missing separator after regex");
84        };
85        if let Some(ifmt) = next.strip_prefix("-") {
86            ensure!(
87                ["b", "c", "d", "p", "l", "s", "-"].contains(&ifmt),
88                "{line_nr}: invalid type code -{ifmt}"
89            );
90            let Some(context) = parts.next() else {
91                bail!("{line_nr}: missing context field");
92            };
93            regexps.push(format!("^({regex}){ifmt}$"));
94            contexts.push(context.to_string());
95        } else {
96            let context = next;
97            regexps.push(format!("^({regex}).$"));
98            contexts.push(context.to_string());
99        }
100        ensure!(parts.next().is_none(), "{line_nr}: trailing data");
101    }
102
103    Ok(())
104}
105
106/* We try to compile all reversed SELinux regex patterns into a single
107 * regex-automata lazy DFA.  If any pattern uses PCRE2-only features
108 * (e.g. lookarounds), the all-at-once build fails and we fall back to
109 * per-pattern classification: a syntax parse identifies the incompatible
110 * patterns, which become individual PCRE2 fallbacks while everything else
111 * goes into one big DFA.
112 *
113 * Lookup searches the DFA for the best (lowest-index) match, then checks
114 * PCRE2 fallbacks that might have even higher priority.  Since fallbacks
115 * are sorted by index we stop as soon as we pass the DFA result.
116 *
117 * The input to the matcher is the filename plus a single file-type
118 * character using the codes from selabel_file(5): 'b','c','d','p','l','s','-'.
119 */
120
121/// Strategy for compiling SELinux regex patterns into matchers.
122/// Only used in tests to compare the three approaches; production
123/// code always uses `Hybrid`.
124#[cfg(test)]
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126enum MatchStrategy {
127    /// Single regex-automata lazy DFA over all patterns.
128    /// Fastest lookup but fails on patterns with PCRE2 features.
129    Dfa,
130    /// Individual PCRE2 regexes with first-match linear scan.
131    Pcre,
132    /// DFA for all compatible patterns + PCRE2 fallback for the rest.
133    Hybrid,
134}
135
136/// Lazy DFA state for the bulk of the patterns.
137struct DfaState {
138    dfa: dfa::DFA,
139    cache: dfa::Cache,
140    /// Maps DFA pattern ID → global context index.
141    context_map: Vec<usize>,
142}
143
144/// A single PCRE2 pattern that couldn't be compiled into the DFA
145/// (e.g. because it uses lookarounds).
146struct PcreFallback {
147    /// Global context index (position in the reversed pattern list).
148    index: usize,
149    regex: Regex,
150}
151
152/// Compiled regex matcher for SELinux file_contexts patterns.
153///
154/// Holds one lazy DFA covering all DFA-compatible patterns plus individual
155/// PCRE2 regexes for patterns that need lookarounds.  Lookup searches both
156/// and returns the lowest-index (= highest-priority) match.
157struct Matcher {
158    /// DFA covering all DFA-compatible patterns, if any.
159    dfa: Option<DfaState>,
160    /// PCRE2 patterns sorted by global index (ascending priority).
161    pcre_fallbacks: Vec<PcreFallback>,
162}
163
164struct Policy {
165    aliases: HashMap<OsString, OsString>,
166    matcher: Matcher,
167    /// Context strings, indexed by reversed position (0 = highest priority).
168    contexts: Vec<String>,
169}
170
171/// Syntax configuration shared between the DFA builder and the pattern
172/// compatibility check.  Patterns that fail to parse with this config
173/// need PCRE2 (e.g. because they use lookarounds).
174fn dfa_syntax_config() -> syntax::Config {
175    syntax::Config::new()
176        .unicode(false)
177        .utf8(false)
178        .line_terminator(0)
179}
180
181fn make_dfa_builder() -> dfa::Builder {
182    let mut builder = dfa::Builder::new();
183    builder.syntax(dfa_syntax_config());
184    builder.configure(
185        dfa::Config::new()
186            .cache_capacity(10_000_000)
187            .skip_cache_capacity_check(true),
188    );
189    builder
190}
191
192/// Check whether a pattern can be compiled by the regex-automata engine.
193/// This is a pure syntax parse — much cheaper than building a DFA.
194fn is_dfa_compatible(syntax_config: &syntax::Config, pattern: &str) -> bool {
195    syntax::parse_with(pattern, syntax_config).is_ok()
196}
197
198impl Matcher {
199    /// Build a compiled matcher from pre-parsed (and reversed) regexp patterns.
200    ///
201    /// Tries to compile all patterns into a single lazy DFA.  If that fails
202    /// (e.g. some patterns use PCRE2 lookarounds), falls back to per-pattern
203    /// classification: compatible patterns go into one DFA, the rest become
204    /// individual PCRE2 regexes.
205    fn build(regexps: &[String]) -> Result<Self> {
206        let builder = make_dfa_builder();
207        match builder.build_many(regexps) {
208            Ok(dfa) => {
209                let cache = dfa.create_cache();
210                Ok(Matcher {
211                    dfa: Some(DfaState {
212                        dfa,
213                        cache,
214                        context_map: (0..regexps.len()).collect(),
215                    }),
216                    pcre_fallbacks: vec![],
217                })
218            }
219            Err(_) => Self::build_partitioned(&builder, regexps),
220        }
221    }
222
223    /// Build a matcher using a specific strategy (for test comparisons).
224    #[cfg(test)]
225    fn build_with_strategy(strategy: MatchStrategy, regexps: &[String]) -> Result<Self> {
226        match strategy {
227            MatchStrategy::Hybrid => Self::build(regexps),
228            MatchStrategy::Dfa => {
229                let dfa = make_dfa_builder().build_many(regexps)?;
230                let cache = dfa.create_cache();
231                Ok(Matcher {
232                    dfa: Some(DfaState {
233                        dfa,
234                        cache,
235                        context_map: (0..regexps.len()).collect(),
236                    }),
237                    pcre_fallbacks: vec![],
238                })
239            }
240            MatchStrategy::Pcre => {
241                let mut fallbacks = Vec::with_capacity(regexps.len());
242                for (i, r) in regexps.iter().enumerate() {
243                    fallbacks.push(PcreFallback {
244                        index: i,
245                        regex: Regex::new(r)
246                            .with_context(|| format!("Compiling PCRE2 regex: {r}"))?,
247                    });
248                }
249                Ok(Matcher {
250                    dfa: None,
251                    pcre_fallbacks: fallbacks,
252                })
253            }
254        }
255    }
256
257    /// Partition patterns: DFA-compatible ones go into one big DFA,
258    /// the rest become individual PCRE2 fallbacks.
259    ///
260    /// Uses a syntax-level parse to classify each pattern, which is much
261    /// cheaper than building a DFA per pattern.
262    fn build_partitioned(builder: &dfa::Builder, regexps: &[String]) -> Result<Self> {
263        let syntax_config = dfa_syntax_config();
264        let mut dfa_indices = Vec::new();
265        let mut dfa_patterns = Vec::new();
266        let mut pcre_fallbacks = Vec::new();
267
268        for (i, pattern) in regexps.iter().enumerate() {
269            if is_dfa_compatible(&syntax_config, pattern) {
270                dfa_indices.push(i);
271                dfa_patterns.push(pattern.as_str());
272            } else {
273                pcre_fallbacks.push(PcreFallback {
274                    index: i,
275                    regex: Regex::new(pattern)
276                        .with_context(|| format!("Compiling PCRE2 regex: {pattern}"))?,
277                });
278            }
279        }
280
281        let dfa_state = if dfa_patterns.is_empty() {
282            None
283        } else {
284            let dfa = builder.build_many(&dfa_patterns)?;
285            let cache = dfa.create_cache();
286            Some(DfaState {
287                dfa,
288                cache,
289                context_map: dfa_indices,
290            })
291        };
292
293        Ok(Matcher {
294            dfa: dfa_state,
295            pcre_fallbacks,
296        })
297    }
298
299    /// Look up a key (filename + file-type byte) and return the matching
300    /// context index, or `None` if no pattern matched.
301    ///
302    /// When both a DFA pattern and a PCRE2 fallback match, the one with
303    /// the lower index (= higher priority) wins.
304    fn lookup(&mut self, key: &[u8]) -> Option<usize> {
305        // Search the DFA for the lowest-index match among DFA patterns.
306        let dfa_idx = self.dfa.as_mut().and_then(|d| {
307            let input = Input::new(key).anchored(Anchored::Yes);
308            d.dfa
309                .try_search_fwd(&mut d.cache, &input)
310                .expect("DFA search error")
311                .map(|hm| d.context_map[hm.pattern().as_usize()])
312        });
313
314        // Scan PCRE2 fallbacks that could beat the DFA match (lower index).
315        // They're sorted by index, so we stop as soon as one matches or
316        // we pass the DFA result.
317        for fb in &self.pcre_fallbacks {
318            if dfa_idx.is_some_and(|d| fb.index >= d) {
319                break;
320            }
321            if fb.regex.is_match(key).unwrap_or(false) {
322                return Some(fb.index);
323            }
324        }
325
326        dfa_idx
327    }
328}
329
330/// Open a file in the composefs store, handling inline vs external files.
331pub fn open_file<H: FsVerityHashValue>(
332    dir: DirectoryRef<'_, H>,
333    filename: impl AsRef<OsStr>,
334    repo: &Repository<H>,
335) -> Result<Option<Box<dyn Read>>> {
336    match dir.get_file_opt(filename.as_ref())? {
337        Some(file) => match file {
338            RegularFile::Inline(data) => Ok(Some(Box::new(Cursor::new(data.clone())))),
339            RegularFile::External(id, ..) | RegularFile::ExternalNoVerity(id, ..) => {
340                Ok(Some(Box::new(File::from(repo.open_object(id)?))))
341            }
342            RegularFile::Sparse(..) => Ok(None),
343        },
344        None => Ok(None),
345    }
346}
347
348/// Open a file from an on-disk directory, returning None if it doesn't exist.
349fn open_file_from_dir(
350    dirfd: impl AsFd,
351    filename: impl AsRef<OsStr>,
352) -> Result<Option<Box<dyn Read>>> {
353    match openat(
354        dirfd,
355        filename.as_ref(),
356        OFlags::RDONLY | OFlags::CLOEXEC,
357        Mode::empty(),
358    ) {
359        Ok(fd) => Ok(Some(Box::new(File::from(fd)))),
360        Err(Errno::NOENT) => Ok(None),
361        Err(e) => Err(e.into()),
362    }
363}
364
365impl Policy {
366    /// Build a SELinux policy from file_contexts files opened via a callback.
367    ///
368    /// The callback takes a filename (e.g. "file_contexts", "file_contexts.subs")
369    /// and returns an optional reader for that file.
370    #[context("Building SELinux policy")]
371    fn build_from(mut open: impl FnMut(&str) -> Result<Option<Box<dyn Read>>>) -> Result<Self> {
372        let mut aliases = HashMap::new();
373        let mut regexps = vec![];
374        let mut contexts = vec![];
375
376        for suffix in ["", ".local", ".homedirs"] {
377            let name = format!("file_contexts{suffix}");
378            if let Some(file) = open(&name)? {
379                process_spec_file(file, &mut regexps, &mut contexts)
380                    .with_context(|| format!("SELinux spec file {name}"))?;
381            } else if suffix.is_empty() {
382                bail!("SELinux policy is missing mandatory file_contexts file");
383            }
384        }
385
386        for suffix in [".subs", ".subs_dist"] {
387            let name = format!("file_contexts{suffix}");
388            if let Some(file) = open(&name)? {
389                process_subs_file(file, &mut aliases)
390                    .with_context(|| format!("SELinux subs file {name}"))?;
391            }
392        }
393
394        // We want to match the last-found.
395        regexps.reverse();
396        contexts.reverse();
397
398        let matcher = Matcher::build(&regexps)?;
399
400        Ok(Policy {
401            aliases,
402            matcher,
403            contexts,
404        })
405    }
406
407    pub fn check_aliased(&self, filename: &OsStr) -> Option<&OsStr> {
408        self.aliases.get(filename).map(|x| x.as_os_str())
409    }
410
411    // mut because it touches the DFA cache
412    pub fn lookup(&mut self, filename: &OsStr, ifmt: u8) -> Option<&str> {
413        let key = [filename.as_bytes(), &[ifmt]].concat();
414        self.matcher.lookup(&key).and_then(|idx| {
415            let ctx = self.contexts[idx].as_str();
416            (ctx != "<<none>>").then_some(ctx)
417        })
418    }
419}
420
421fn relabel(stat: &mut Stat, path: &Path, ifmt: u8, policy: &mut Policy) {
422    let key = OsStr::new(XATTR_SECURITY_SELINUX);
423
424    if let Some(label) = policy.lookup(path.as_os_str(), ifmt) {
425        stat.xattrs
426            .insert(Box::from(key), Box::from(label.as_bytes()));
427    } else {
428        stat.xattrs.remove(key);
429    }
430}
431
432fn relabel_dir<H: FsVerityHashValue>(
433    dir: &mut Directory<H>,
434    leaves: &mut Vec<Leaf<H>>,
435    path: &mut PathBuf,
436    policy: &mut Policy,
437    // Tracks the SELinux label committed when a LeafId was first labeled.
438    // `None` means the leaf was labeled but with no security.selinux xattr.
439    // Absence from the map means the leaf hasn't been labeled yet.
440    labeled: &mut HashMap<composefs::generic_tree::LeafId, Option<Box<[u8]>>>,
441) {
442    use composefs::generic_tree::LeafId;
443
444    relabel(&mut dir.stat, path, b'd', policy);
445
446    // Collect entry names and types to avoid borrow conflicts during mutation.
447    let children: Vec<(Box<OsStr>, Option<LeafId>)> = dir
448        .sorted_entries()
449        .map(|(name, inode)| {
450            let id = match inode {
451                Inode::Leaf(id, _) => Some(*id),
452                Inode::Directory(_) => None,
453            };
454            (Box::from(name), id)
455        })
456        .collect();
457
458    for (name, leaf_id) in children {
459        path.push(Path::new(&name));
460        let aliased_path = policy.check_aliased(path.as_os_str()).map(PathBuf::from);
461        let effective_path = aliased_path.as_deref().unwrap_or(path.as_path());
462
463        if let Some(id) = leaf_id {
464            // Compute what label this path would get.
465            let ifmt = match leaves[id.0].content {
466                LeafContent::Regular(..) => b'-',
467                LeafContent::Fifo => b'p',
468                LeafContent::Socket => b's',
469                LeafContent::Symlink(..) => b'l',
470                LeafContent::BlockDevice(..) => b'b',
471                LeafContent::CharacterDevice(..) => b'c',
472            };
473            let new_label: Option<&str> = policy.lookup(effective_path.as_os_str(), ifmt);
474
475            // Check if this LeafId was already labeled (i.e., is a hardlink).
476            let effective_id = if let Some(prev_label) = labeled.get(&id) {
477                // Compare the previously-committed label with the new one.
478                let labels_match = match (prev_label.as_deref(), new_label) {
479                    (Some(p), Some(n)) => p == n.as_bytes(),
480                    (None, None) => true,
481                    _ => false,
482                };
483
484                if labels_match {
485                    // Same label: share the leaf as-is.
486                    id
487                } else {
488                    // Different label: break the hardlink by cloning the leaf
489                    // into a new slot and updating this directory entry to
490                    // point to the clone.
491                    let clone = leaves[id.0].clone();
492                    let new_id = LeafId(leaves.len());
493                    leaves.push(clone);
494                    // Update the directory entry to use the new LeafId.
495                    dir.remap_leaf(name.as_ref(), new_id);
496                    new_id
497                }
498            } else {
499                id
500            };
501
502            // Apply the label to the (possibly cloned) leaf.
503            let key = OsStr::new(XATTR_SECURITY_SELINUX);
504            if let Some(label) = new_label {
505                leaves[effective_id.0]
506                    .stat
507                    .xattrs
508                    .insert(Box::from(key), Box::from(label.as_bytes()));
509            } else {
510                leaves[effective_id.0].stat.xattrs.remove(key);
511            }
512
513            // Record the label committed to this LeafId.
514            labeled
515                .entry(effective_id)
516                .or_insert_with(|| new_label.map(|l| Box::from(l.as_bytes())));
517        } else {
518            let mut sub_path = effective_path.to_path_buf();
519            let subdir = dir.get_directory_mut(name.as_ref()).unwrap();
520            relabel_dir(subdir, leaves, &mut sub_path, policy, labeled);
521        }
522
523        path.pop();
524    }
525}
526
527fn parse_config(file: impl Read) -> Result<Option<String>> {
528    for line in BufReader::new(file).lines() {
529        if let Some((key, value)) = line?.split_once('=') {
530            // this might be a comment, but then key will start with '#'
531            if key.trim().eq_ignore_ascii_case("SELINUXTYPE") {
532                return Ok(Some(value.trim().to_string()));
533            }
534        }
535    }
536    Ok(None)
537}
538
539fn strip_selinux_labels<H: FsVerityHashValue>(fs: &mut FileSystem<H>) {
540    fs.for_each_stat_mut(|stat| {
541        stat.xattrs.remove(OsStr::new(XATTR_SECURITY_SELINUX));
542    });
543}
544
545/// Build a Policy from a file-open callback, or return None if /etc/selinux/config
546/// is missing or doesn't specify a policy type.
547fn build_policy(
548    mut open_config: impl FnMut(&str) -> Result<Option<Box<dyn Read>>>,
549    mut open_policy_file: impl FnMut(&str, &str) -> Result<Option<Box<dyn Read>>>,
550) -> Result<Option<Policy>> {
551    let Some(etc_selinux_config) = open_config("config")? else {
552        return Ok(None);
553    };
554
555    let Some(policy_name) = parse_config(etc_selinux_config)? else {
556        return Ok(None);
557    };
558
559    let policy = Policy::build_from(|filename| open_policy_file(&policy_name, filename))?;
560    Ok(Some(policy))
561}
562
563/// Apply a pre-built policy to the filesystem tree, or strip labels if no policy.
564fn apply_policy<H: FsVerityHashValue>(fs: &mut FileSystem<H>, policy: Option<Policy>) -> bool {
565    match policy {
566        Some(mut policy) => {
567            let mut path = PathBuf::from("/");
568            let mut labeled = HashMap::new();
569            let FileSystem { root, leaves } = fs;
570            relabel_dir(root, leaves, &mut path, &mut policy, &mut labeled);
571            true
572        }
573        None => {
574            strip_selinux_labels(fs);
575            false
576        }
577    }
578}
579
580/// Applies SELinux security contexts to all files in a filesystem tree.
581///
582/// Reads the SELinux policy from /etc/selinux/config and corresponding policy files,
583/// then labels all filesystem nodes with appropriate security.selinux extended attributes.
584///
585/// If no SELinux policy is found in the target filesystem, any existing `security.selinux`
586/// xattrs are stripped. This prevents build-time SELinux labels (e.g., `container_t`) from
587/// leaking into the final image when targeting a non-SELinux host.
588///
589/// # Arguments
590///
591/// * `fs` - The filesystem to label
592/// * `repo` - The composefs repository
593///
594/// # Returns
595///
596/// Returns `Ok(true)` if SELinux labeling was performed (policy was found),
597/// or `Ok(false)` if no policy was found and existing labels were stripped.
598#[context("Applying SELinux labels to filesystem")]
599pub fn selabel<H: FsVerityHashValue>(fs: &mut FileSystem<H>, repo: &Repository<H>) -> Result<bool> {
600    // Build the policy while only borrowing fs.root immutably.
601    let policy = {
602        let root = fs.as_dir();
603        let Some(etc_selinux) = root.get_directory_ref_opt("etc/selinux".as_ref())? else {
604            strip_selinux_labels(fs);
605            return Ok(false);
606        };
607
608        build_policy(
609            |filename| open_file(etc_selinux, filename, repo),
610            |policy_name, filename| {
611                let dir = etc_selinux
612                    .get_directory_ref(policy_name.as_ref())?
613                    .get_directory_ref("contexts/files".as_ref())?;
614                open_file(dir, filename, repo)
615            },
616        )?
617    };
618
619    // Now we can mutably borrow fs for relabeling.
620    Ok(apply_policy(fs, policy))
621}
622
623/// Applies SELinux security contexts by reading policy files from an on-disk directory.
624///
625/// This is an alternative to [`selabel`] that reads SELinux policy files directly from
626/// a mounted filesystem via a directory file descriptor, rather than from a composefs
627/// repository. This avoids the need to store file objects in the repository just to
628/// compute SELinux labels.
629///
630/// The directory fd should point to the root of the filesystem being labeled
631/// (the same filesystem that was read into the `FileSystem` tree).
632///
633/// # Arguments
634///
635/// * `fs` - The filesystem tree to label
636/// * `rootfs` - A directory fd pointing to the root of the on-disk filesystem
637///
638/// # Returns
639///
640/// Returns `Ok(true)` if SELinux labeling was performed (policy was found),
641/// or `Ok(false)` if no policy was found and existing labels were stripped.
642#[context("Applying SELinux labels to filesystem from directory")]
643pub fn selabel_from_dir(
644    fs: &mut FileSystem<impl FsVerityHashValue>,
645    rootfs: impl AsFd,
646) -> Result<bool> {
647    // Open /etc/selinux as a directory fd, treating NOENT as "no policy"
648    let etc_selinux = match openat(
649        &rootfs,
650        "etc/selinux",
651        OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
652        Mode::empty(),
653    ) {
654        Ok(fd) => fd,
655        Err(Errno::NOENT) => {
656            strip_selinux_labels(fs);
657            return Ok(false);
658        }
659        Err(e) => return Err(e.into()),
660    };
661
662    let policy = build_policy(
663        |filename| open_file_from_dir(&etc_selinux, filename),
664        |policy_name, filename| {
665            let path = format!("{policy_name}/contexts/files/{filename}");
666            open_file_from_dir(&etc_selinux, path)
667        },
668    )?;
669
670    Ok(apply_policy(fs, policy))
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676
677    use composefs::dumpfile::dumpfile_to_filesystem;
678    use composefs::fsverity::Sha256HashValue;
679    use composefs::generic_tree::LeafId;
680    use composefs::test::TestRepo;
681    use indoc::indoc;
682
683    /// Walk the directory tree and collect every LeafId referenced anywhere in it.
684    fn collect_leaf_ids(dir: &Directory<Sha256HashValue>) -> Vec<LeafId> {
685        let mut ids = Vec::new();
686        for inode in dir.inodes() {
687            match inode {
688                Inode::Directory(sub) => ids.extend(collect_leaf_ids(sub)),
689                Inode::Leaf(id, _) => ids.push(*id),
690            }
691        }
692        ids
693    }
694
695    /// Assert that no LeafId is referenced more than once in the filesystem —
696    /// i.e., after selabel has broken all cross-domain hardlinks, every path
697    /// has its own unique inode.
698    fn assert_no_hardlinks(fs: &FileSystem<Sha256HashValue>) {
699        let ids = collect_leaf_ids(&fs.root);
700        let mut seen = std::collections::HashSet::new();
701        for id in &ids {
702            assert!(
703                seen.insert(id.0),
704                "LeafId {} is shared between two paths after selabel (hardlink not broken)",
705                id.0,
706            );
707        }
708    }
709
710    /// Get the SELinux label from a Stat's xattrs, if any.
711    fn selinux_label(stat: &Stat) -> Option<String> {
712        stat.xattrs
713            .get(OsStr::new(XATTR_SECURITY_SELINUX))
714            .map(|v| String::from_utf8_lossy(v).into())
715    }
716
717    /// Look up a path in the filesystem and return its SELinux label.
718    ///
719    /// Panics if the path doesn't exist.  Returns `None` if the node
720    /// has no `security.selinux` xattr.
721    fn get_label(fs: &FileSystem<Sha256HashValue>, path: &str) -> Option<String> {
722        if path == "/" {
723            return selinux_label(&fs.root.stat);
724        }
725        let p = Path::new(path);
726        let parent = p.parent().unwrap();
727        let name = p.file_name().unwrap();
728        let root = fs.as_dir();
729        let dir = if parent == Path::new("/") {
730            root
731        } else {
732            root.get_directory_ref(parent.as_os_str()).unwrap()
733        };
734        match dir
735            .lookup(name)
736            .unwrap_or_else(|| panic!("{path} not found"))
737        {
738            Inode::Directory(d) => selinux_label(&d.stat),
739            Inode::Leaf(leaf_id, _) => selinux_label(&fs.leaf(*leaf_id).stat),
740        }
741    }
742
743    /// Build a filesystem with an embedded SELinux policy from the given
744    /// raw file_contexts content, then merge in additional entries from a
745    /// dumpfile string.
746    ///
747    /// `file_contexts` and values in `extra_policy_files` are raw bytes
748    /// (real tabs, newlines, etc.).
749    ///
750    /// `extra_policy_files` can supply additional policy files like
751    /// `file_contexts.local` or `file_contexts.subs`.
752    fn build_fs_with_selinux(
753        file_contexts: &[u8],
754        extra_policy_files: &[(&str, &[u8])],
755        fs_entries: &str,
756    ) -> FileSystem<Sha256HashValue> {
757        use composefs::dumpfile::write_dumpfile;
758
759        let dir_stat = || Stat {
760            st_mode: 0o40755,
761            st_uid: 0,
762            st_gid: 0,
763            st_mtim_sec: 0,
764            st_mtim_nsec: 0,
765            xattrs: Default::default(),
766        };
767
768        let mut fs = FileSystem::<Sha256HashValue>::new(dir_stat());
769
770        // Helper: push an inline file leaf and return its Inode.
771        let push_inline =
772            |fs: &mut FileSystem<Sha256HashValue>, data: &[u8]| -> Inode<Sha256HashValue> {
773                let id = fs.push_leaf(
774                    Stat {
775                        st_mode: 0o100644,
776                        st_uid: 0,
777                        st_gid: 0,
778                        st_mtim_sec: 0,
779                        st_mtim_nsec: 0,
780                        xattrs: Default::default(),
781                    },
782                    LeafContent::Regular(RegularFile::Inline(data.to_vec().into_boxed_slice())),
783                );
784                Inode::leaf(id)
785            };
786
787        // Build a tree containing the SELinux policy files, serialize it
788        // via the dumpfile writer so escaping is handled correctly, then
789        // append the caller's additional entries and parse the whole thing.
790        let selinux_config = b"SELINUX=enforcing\nSELINUXTYPE=targeted\n";
791
792        // Create the directory tree
793        for path in [
794            "etc",
795            "etc/selinux",
796            "etc/selinux/targeted",
797            "etc/selinux/targeted/contexts",
798            "etc/selinux/targeted/contexts/files",
799        ] {
800            let (dir, name) = fs.root.split_mut(path.as_ref()).unwrap();
801            dir.insert(name, Inode::Directory(Box::new(Directory::new(dir_stat()))));
802        }
803        let config_inode = push_inline(&mut fs, selinux_config);
804        fs.root
805            .get_directory_mut("etc/selinux".as_ref())
806            .unwrap()
807            .insert(OsStr::new("config"), config_inode);
808
809        // Insert file_contexts and extra policy files
810        let fc_inode = push_inline(&mut fs, file_contexts);
811        let extra_inodes: Vec<_> = extra_policy_files
812            .iter()
813            .map(|(name, content)| (name.to_string(), push_inline(&mut fs, content)))
814            .collect();
815
816        let files_dir = fs
817            .root
818            .get_directory_mut("etc/selinux/targeted/contexts/files".as_ref())
819            .unwrap();
820        files_dir.insert(OsStr::new("file_contexts"), fc_inode);
821        for (name, inode) in extra_inodes {
822            files_dir.insert(OsStr::new(&name), inode);
823        }
824
825        // Serialize via the proper dumpfile writer, append extra entries, re-parse
826        let mut buf = Vec::new();
827        write_dumpfile(&mut buf, &fs).unwrap();
828        let mut dumpfile = String::from_utf8(buf).unwrap();
829        dumpfile.push_str(fs_entries);
830        dumpfile_to_filesystem(&dumpfile).unwrap()
831    }
832
833    /// Verify that selabel() applies the correct SELinux contexts from
834    /// an in-memory filesystem's embedded policy files.
835    #[test]
836    fn selabel_applies_correct_labels() {
837        let file_contexts = indoc! {b"
838            /\t\tsystem_u:object_r:root_t:s0
839            /usr\t\tsystem_u:object_r:usr_t:s0
840            /usr/bin(/.*)?\t\tsystem_u:object_r:bin_t:s0
841            /etc(/.*)?\t\tsystem_u:object_r:etc_t:s0
842        "};
843
844        let fs_entries = "\
845/boot 0 40755 2 0 0 0 0.0 - - -
846/etc/hostname 9 100644 1 0 0 0 0.0 - testhost\\n -
847/sysroot 0 40755 2 0 0 0 0.0 - - -
848/usr 0 40755 2 0 0 0 1000.0 - - -
849/usr/bin 0 40755 2 0 0 0 1000.0 - - -
850/usr/bin/hello 21 100755 1 0 0 0 0.0 - #!/bin/sh\\necho\\x20hello\\n -
851";
852        let mut fs = build_fs_with_selinux(file_contexts, &[], fs_entries);
853        let test_repo = TestRepo::<Sha256HashValue>::new();
854
855        assert!(selabel(&mut fs, &test_repo.repo).unwrap());
856
857        assert_eq!(get_label(&fs, "/").unwrap(), "system_u:object_r:root_t:s0");
858        assert_eq!(
859            get_label(&fs, "/usr").unwrap(),
860            "system_u:object_r:usr_t:s0"
861        );
862        assert_eq!(
863            get_label(&fs, "/usr/bin").unwrap(),
864            "system_u:object_r:bin_t:s0"
865        );
866        assert_eq!(
867            get_label(&fs, "/usr/bin/hello").unwrap(),
868            "system_u:object_r:bin_t:s0"
869        );
870        assert_eq!(
871            get_label(&fs, "/etc").unwrap(),
872            "system_u:object_r:etc_t:s0"
873        );
874        assert_eq!(
875            get_label(&fs, "/etc/hostname").unwrap(),
876            "system_u:object_r:etc_t:s0"
877        );
878    }
879
880    /// Verify that selabel() strips pre-existing labels when no policy is found.
881    #[test]
882    fn selabel_strips_when_no_policy() {
883        let dumpfile = "\
884/ 0 40755 2 0 0 0 0.0 - - -
885/file 1 100644 1 0 0 0 0.0 - x - security.selinux=old_label
886";
887        let mut fs = dumpfile_to_filesystem::<Sha256HashValue>(dumpfile).unwrap();
888        let test_repo = TestRepo::<Sha256HashValue>::new();
889
890        assert!(!selabel(&mut fs, &test_repo.repo).unwrap());
891        assert!(get_label(&fs, "/").is_none());
892        assert!(get_label(&fs, "/file").is_none());
893    }
894
895    /// Verify that type-specific file_contexts rules (e.g. `-d`, `--`, `-l`)
896    /// label different inode types independently.
897    #[test]
898    fn selabel_type_specific_labels() {
899        // /var/log directories get var_log_dir_t, regular files get
900        // var_log_t, and symlinks get var_log_link_t.
901        let file_contexts = indoc! {b"
902            /var(/.*)?		system_u:object_r:var_t:s0
903            /var/log(/.*)? -d system_u:object_r:var_log_dir_t:s0
904            /var/log(/.*)? -- system_u:object_r:var_log_t:s0
905            /var/log(/.*)? -l system_u:object_r:var_log_link_t:s0
906        "};
907
908        let fs_entries = "\
909/var 0 40755 2 0 0 0 0.0 - - -
910/var/log 0 40755 2 0 0 0 0.0 - - -
911/var/log/messages 10 100644 1 0 0 0 0.0 - 0123456789 -
912/var/log/current 4 120777 1 0 0 0 0.0 /var/log/messages - -
913";
914        let mut fs = build_fs_with_selinux(file_contexts, &[], fs_entries);
915        let test_repo = TestRepo::<Sha256HashValue>::new();
916
917        assert!(selabel(&mut fs, &test_repo.repo).unwrap());
918
919        assert_eq!(
920            get_label(&fs, "/var").unwrap(),
921            "system_u:object_r:var_t:s0"
922        );
923        assert_eq!(
924            get_label(&fs, "/var/log").unwrap(),
925            "system_u:object_r:var_log_dir_t:s0"
926        );
927        assert_eq!(
928            get_label(&fs, "/var/log/messages").unwrap(),
929            "system_u:object_r:var_log_t:s0"
930        );
931        assert_eq!(
932            get_label(&fs, "/var/log/current").unwrap(),
933            "system_u:object_r:var_log_link_t:s0"
934        );
935    }
936
937    /// Verify that file_contexts.subs aliases redirect labeling lookups.
938    #[test]
939    fn selabel_subs_aliases() {
940        let file_contexts = indoc! {b"
941            /home(/.*)?		system_u:object_r:home_t:s0
942        "};
943        let subs_content = b"/srv/home /home\n";
944
945        let fs_entries = "\
946/home 0 40755 2 0 0 0 0.0 - - -
947/home/user.txt 5 100644 1 0 0 0 0.0 - hello -
948/srv 0 40755 2 0 0 0 0.0 - - -
949/srv/home 0 40755 2 0 0 0 0.0 - - -
950/srv/home/data.txt 5 100644 1 0 0 0 0.0 - world -
951";
952        let mut fs = build_fs_with_selinux(
953            file_contexts,
954            &[("file_contexts.subs", subs_content)],
955            fs_entries,
956        );
957        let test_repo = TestRepo::<Sha256HashValue>::new();
958
959        assert!(selabel(&mut fs, &test_repo.repo).unwrap());
960
961        assert_eq!(
962            get_label(&fs, "/home").unwrap(),
963            "system_u:object_r:home_t:s0"
964        );
965        assert_eq!(
966            get_label(&fs, "/home/user.txt").unwrap(),
967            "system_u:object_r:home_t:s0"
968        );
969        assert_eq!(
970            get_label(&fs, "/srv/home").unwrap(),
971            "system_u:object_r:home_t:s0"
972        );
973        assert_eq!(
974            get_label(&fs, "/srv/home/data.txt").unwrap(),
975            "system_u:object_r:home_t:s0"
976        );
977    }
978
979    /// Verify that <<none>> in file_contexts suppresses labeling.
980    #[test]
981    fn selabel_none_context() {
982        let file_contexts = indoc! {b"
983            /tmp(/.*)?		system_u:object_r:tmp_t:s0
984            /tmp/private(/.*)?		<<none>>
985        "};
986
987        let fs_entries = "\
988/tmp 0 40755 2 0 0 0 0.0 - - -
989/tmp/scratch.txt 5 100644 1 0 0 0 0.0 - hello -
990/tmp/private 0 40755 2 0 0 0 0.0 - - -
991/tmp/private/secret.txt 6 100644 1 0 0 0 0.0 - secret -
992";
993        let mut fs = build_fs_with_selinux(file_contexts, &[], fs_entries);
994        let test_repo = TestRepo::<Sha256HashValue>::new();
995
996        assert!(selabel(&mut fs, &test_repo.repo).unwrap());
997
998        assert_eq!(
999            get_label(&fs, "/tmp").unwrap(),
1000            "system_u:object_r:tmp_t:s0"
1001        );
1002        assert_eq!(
1003            get_label(&fs, "/tmp/scratch.txt").unwrap(),
1004            "system_u:object_r:tmp_t:s0"
1005        );
1006        assert!(get_label(&fs, "/tmp/private").is_none());
1007        assert!(get_label(&fs, "/tmp/private/secret.txt").is_none());
1008    }
1009
1010    /// Verify that file_contexts.local overrides are processed.
1011    #[test]
1012    fn selabel_local_overrides() {
1013        let file_contexts = indoc! {b"
1014            /opt(/.*)?		system_u:object_r:opt_t:s0
1015        "};
1016        let local_content = indoc! {b"
1017            /opt/custom(/.*)?		system_u:object_r:custom_t:s0
1018        "};
1019
1020        let fs_entries = "\
1021/opt 0 40755 2 0 0 0 0.0 - - -
1022/opt/readme.txt 7 100644 1 0 0 0 0.0 - default -
1023/opt/custom 0 40755 2 0 0 0 0.0 - - -
1024/opt/custom/app 3 100755 1 0 0 0 0.0 - app -
1025";
1026        let mut fs = build_fs_with_selinux(
1027            file_contexts,
1028            &[("file_contexts.local", local_content)],
1029            fs_entries,
1030        );
1031        let test_repo = TestRepo::<Sha256HashValue>::new();
1032
1033        assert!(selabel(&mut fs, &test_repo.repo).unwrap());
1034
1035        assert_eq!(
1036            get_label(&fs, "/opt").unwrap(),
1037            "system_u:object_r:opt_t:s0"
1038        );
1039        assert_eq!(
1040            get_label(&fs, "/opt/readme.txt").unwrap(),
1041            "system_u:object_r:opt_t:s0"
1042        );
1043        assert_eq!(
1044            get_label(&fs, "/opt/custom").unwrap(),
1045            "system_u:object_r:custom_t:s0"
1046        );
1047        assert_eq!(
1048            get_label(&fs, "/opt/custom/app").unwrap(),
1049            "system_u:object_r:custom_t:s0"
1050        );
1051    }
1052
1053    /// Verify labeling of device nodes and FIFOs with type-specific rules.
1054    #[test]
1055    fn selabel_device_and_fifo_labels() {
1056        let file_contexts = indoc! {b"
1057            /dev(/.*)?		system_u:object_r:device_t:s0
1058            /dev(/.*)? -b system_u:object_r:fixed_disk_device_t:s0
1059            /dev(/.*)? -c system_u:object_r:tty_device_t:s0
1060            /dev(/.*)? -p system_u:object_r:fifo_t:s0
1061        "};
1062
1063        let fs_entries = "\
1064/dev 0 40755 2 0 0 0 0.0 - - -
1065/dev/sda 0 60660 1 0 0 2049 0.0 - - -
1066/dev/tty0 0 20666 1 0 0 1024 0.0 - - -
1067/dev/initctl 0 10644 1 0 0 0 0.0 - - -
1068";
1069        let mut fs = build_fs_with_selinux(file_contexts, &[], fs_entries);
1070        let test_repo = TestRepo::<Sha256HashValue>::new();
1071
1072        assert!(selabel(&mut fs, &test_repo.repo).unwrap());
1073
1074        assert_eq!(
1075            get_label(&fs, "/dev").unwrap(),
1076            "system_u:object_r:device_t:s0"
1077        );
1078        assert_eq!(
1079            get_label(&fs, "/dev/sda").unwrap(),
1080            "system_u:object_r:fixed_disk_device_t:s0"
1081        );
1082        assert_eq!(
1083            get_label(&fs, "/dev/tty0").unwrap(),
1084            "system_u:object_r:tty_device_t:s0"
1085        );
1086        assert_eq!(
1087            get_label(&fs, "/dev/initctl").unwrap(),
1088            "system_u:object_r:fifo_t:s0"
1089        );
1090    }
1091
1092    /// Verify that hardlinked files that receive *different* SELinux labels from
1093    /// the policy are given independent labels — the hardlink is "broken" in the
1094    /// in-memory tree so each path has its own Stat with the correct label.
1095    ///
1096    /// Without this fix, `selabel` would overwrite the first path's label with the
1097    /// second path's label (since both point at the same `leaves[id]` slot).
1098    #[test]
1099    fn selabel_breaks_hardlinks_with_different_labels() {
1100        // /usr/bin/foo gets usr_t, /opt/foo (hardlink) gets opt_t.
1101        let file_contexts = indoc! {b"
1102            /(/.*)?		system_u:object_r:default_t:s0
1103            /usr(/.*)?	system_u:object_r:usr_t:s0
1104            /opt(/.*)?	system_u:object_r:opt_t:s0
1105        "};
1106
1107        // /usr/bin/foo is written first (the "original"); /opt/foo is a hardlink.
1108        // Note: /etc already exists in the tree (SELinux policy lives there),
1109        // so we use /opt as the second directory to avoid conflicts.
1110        // The original must appear before the hardlink in dumpfile order.
1111        let fs_entries = "\
1112/opt 0 40755 2 0 0 0 0.0 - - -
1113/usr 0 40755 2 0 0 0 0.0 - - -
1114/usr/bin 0 40755 2 0 0 0 0.0 - - -
1115/usr/bin/foo 5 100644 2 0 0 0 0.0 - hello -
1116/opt/foo 0 @120000 - - - - 0.0 /usr/bin/foo - -
1117";
1118        let mut fs = build_fs_with_selinux(file_contexts, &[], fs_entries);
1119        let test_repo = TestRepo::<Sha256HashValue>::new();
1120
1121        assert!(selabel(&mut fs, &test_repo.repo).unwrap());
1122
1123        // Each path must carry its own correct label.
1124        assert_eq!(
1125            get_label(&fs, "/usr/bin/foo"),
1126            Some("system_u:object_r:usr_t:s0".into()),
1127            "/usr/bin/foo should have usr_t"
1128        );
1129        assert_eq!(
1130            get_label(&fs, "/opt/foo"),
1131            Some("system_u:object_r:opt_t:s0".into()),
1132            "/opt/foo should have opt_t"
1133        );
1134
1135        // After breaking the hardlink the two entries must refer to *different* LeafIds.
1136        let usr_bin = fs.as_dir().get_directory_ref("usr/bin".as_ref()).unwrap();
1137        let opt = fs.as_dir().get_directory_ref("opt".as_ref()).unwrap();
1138        let foo_usr_id = match usr_bin.lookup(OsStr::new("foo")).unwrap() {
1139            Inode::Leaf(id, _) => *id,
1140            _ => panic!("expected leaf"),
1141        };
1142        let foo_opt_id = match opt.lookup(OsStr::new("foo")).unwrap() {
1143            Inode::Leaf(id, _) => *id,
1144            _ => panic!("expected leaf"),
1145        };
1146        assert_ne!(
1147            foo_usr_id, foo_opt_id,
1148            "hardlink should have been broken into separate LeafIds"
1149        );
1150    }
1151
1152    /// Simulate the real-world Fedora/CentOS bootc pattern where RPM packages
1153    /// hardlink license files between `/usr/lib/<pkg>/` (gets `lib_t`) and
1154    /// `/usr/share/licenses/<pkg>/` (gets `usr_t`).
1155    ///
1156    /// After selabel the filesystem must contain **no hardlinks at all** —
1157    /// every path must reference its own unique LeafId so that each file
1158    /// carries the label dictated by its own location.
1159    ///
1160    /// This is the pattern observed in `ghcr.io/bootc-dev/dev-bootc:fedora-44-uki`
1161    /// where ~70 files triggered the hardlink-breaking path.
1162    #[test]
1163    fn selabel_no_hardlinks_after_labeling_bootable_layout() {
1164        // Approximate the Fedora targeted policy distinctions that matter here:
1165        //   /usr/lib(/.*)?  -> lib_t   (libraries and their bundled docs)
1166        //   /usr/share(/.*)? -> usr_t  (architecture-independent data)
1167        let file_contexts = indoc! {b"
1168            /(/.*)?                             system_u:object_r:default_t:s0
1169            /usr(/.*)?                          system_u:object_r:usr_t:s0
1170            /usr/lib(/.*)?                      system_u:object_r:lib_t:s0
1171            /usr/share(/.*)?                    system_u:object_r:usr_t:s0
1172        "};
1173
1174        // Three packages, each with a file in /usr/lib/<pkg>/ hardlinked to
1175        // /usr/share/licenses/<pkg>/COPYING — exactly the pattern RPM uses to
1176        // share identical license text across sub-packages.
1177        //
1178        // The "primary" inode is listed first (under /usr/lib); the
1179        // /usr/share/licenses entry is a hardlink (@120000 notation) back to it.
1180        let fs_entries = "\
1181/usr 0 40755 2 0 0 0 0.0 - - -
1182/usr/lib 0 40755 2 0 0 0 0.0 - - -
1183/usr/lib/pkgA 0 40755 2 0 0 0 0.0 - - -
1184/usr/lib/pkgA/COPYING 674 100644 2 0 0 0 0.0 - GPL2 -
1185/usr/lib/pkgB 0 40755 2 0 0 0 0.0 - - -
1186/usr/lib/pkgB/COPYING 674 100644 2 0 0 0 0.0 - GPL2 -
1187/usr/lib/pkgC 0 40755 2 0 0 0 0.0 - - -
1188/usr/lib/pkgC/COPYING 1024 100644 2 0 0 0 0.0 - APACHE2 -
1189/usr/share 0 40755 2 0 0 0 0.0 - - -
1190/usr/share/licenses 0 40755 2 0 0 0 0.0 - - -
1191/usr/share/licenses/pkgA 0 40755 2 0 0 0 0.0 - - -
1192/usr/share/licenses/pkgA/COPYING 0 @120000 - - - - 0.0 /usr/lib/pkgA/COPYING - -
1193/usr/share/licenses/pkgB 0 40755 2 0 0 0 0.0 - - -
1194/usr/share/licenses/pkgB/COPYING 0 @120000 - - - - 0.0 /usr/lib/pkgB/COPYING - -
1195/usr/share/licenses/pkgC 0 40755 2 0 0 0 0.0 - - -
1196/usr/share/licenses/pkgC/COPYING 0 @120000 - - - - 0.0 /usr/lib/pkgC/COPYING - -
1197";
1198        let mut fs = build_fs_with_selinux(file_contexts, &[], fs_entries);
1199        let test_repo = TestRepo::<Sha256HashValue>::new();
1200
1201        assert!(selabel(&mut fs, &test_repo.repo).unwrap());
1202
1203        // The /usr/lib files get lib_t; the /usr/share/licenses files get usr_t.
1204        assert_eq!(
1205            get_label(&fs, "/usr/lib/pkgA/COPYING"),
1206            Some("system_u:object_r:lib_t:s0".into()),
1207        );
1208        assert_eq!(
1209            get_label(&fs, "/usr/share/licenses/pkgA/COPYING"),
1210            Some("system_u:object_r:usr_t:s0".into()),
1211        );
1212        assert_eq!(
1213            get_label(&fs, "/usr/lib/pkgB/COPYING"),
1214            Some("system_u:object_r:lib_t:s0".into()),
1215        );
1216        assert_eq!(
1217            get_label(&fs, "/usr/share/licenses/pkgB/COPYING"),
1218            Some("system_u:object_r:usr_t:s0".into()),
1219        );
1220        assert_eq!(
1221            get_label(&fs, "/usr/lib/pkgC/COPYING"),
1222            Some("system_u:object_r:lib_t:s0".into()),
1223        );
1224        assert_eq!(
1225            get_label(&fs, "/usr/share/licenses/pkgC/COPYING"),
1226            Some("system_u:object_r:usr_t:s0".into()),
1227        );
1228
1229        // The target filesystem must not contain any residual hardlinks —
1230        // every path must have its own unique leaf so each can carry its own label.
1231        assert_no_hardlinks(&fs);
1232    }
1233
1234    /// Verify that a positive lookahead (PCRE2-only feature) in file_contexts
1235    /// forces the affected chunk to fall back to pcre2 and still labels correctly.
1236    #[test]
1237    fn selabel_pcre2_positive_lookahead() {
1238        let file_contexts = indoc! {b"
1239            /(/.*)?		system_u:object_r:default_t:s0
1240            /opt(/.*)?		system_u:object_r:opt_t:s0
1241            /opt/(?=protected).*		system_u:object_r:protected_t:s0
1242        "};
1243
1244        let fs_entries = "\
1245/opt 0 40755 2 0 0 0 0.0 - - -
1246/opt/protected_data 5 100644 1 0 0 0 0.0 - hello -
1247/opt/other_file 5 100644 1 0 0 0 0.0 - world -
1248";
1249        let mut fs = build_fs_with_selinux(file_contexts, &[], fs_entries);
1250        let test_repo = TestRepo::<Sha256HashValue>::new();
1251
1252        assert!(selabel(&mut fs, &test_repo.repo).unwrap());
1253
1254        // Lookahead matches: "protected_data" starts with "protected"
1255        assert_eq!(
1256            get_label(&fs, "/opt/protected_data").unwrap(),
1257            "system_u:object_r:protected_t:s0"
1258        );
1259        // Lookahead does not match: falls through to /opt(/.*)?
1260        assert_eq!(
1261            get_label(&fs, "/opt/other_file").unwrap(),
1262            "system_u:object_r:opt_t:s0"
1263        );
1264    }
1265
1266    /// Verify that a negative lookahead (PCRE2-only feature) correctly excludes
1267    /// paths from matching while still labeling non-excluded paths.
1268    #[test]
1269    fn selabel_pcre2_negative_lookahead() {
1270        // The negative lookahead (?!backup) excludes filenames starting with "backup".
1271        let file_contexts = indoc! {b"
1272            /(/.*)?		system_u:object_r:default_t:s0
1273            /srv(/.*)?		system_u:object_r:srv_t:s0
1274            /srv/(?!backup).*		system_u:object_r:srv_public_t:s0
1275        "};
1276
1277        let fs_entries = "\
1278/srv 0 40755 2 0 0 0 0.0 - - -
1279/srv/website 5 100644 1 0 0 0 0.0 - hello -
1280/srv/backup_2024 5 100644 1 0 0 0 0.0 - world -
1281";
1282        let mut fs = build_fs_with_selinux(file_contexts, &[], fs_entries);
1283        let test_repo = TestRepo::<Sha256HashValue>::new();
1284
1285        assert!(selabel(&mut fs, &test_repo.repo).unwrap());
1286
1287        // Negative lookahead succeeds: "website" doesn't start with "backup"
1288        assert_eq!(
1289            get_label(&fs, "/srv/website").unwrap(),
1290            "system_u:object_r:srv_public_t:s0"
1291        );
1292        // Negative lookahead fails: "backup_2024" starts with "backup",
1293        // so the pattern doesn't match; falls through to /srv(/.*)?
1294        assert_eq!(
1295            get_label(&fs, "/srv/backup_2024").unwrap(),
1296            "system_u:object_r:srv_t:s0"
1297        );
1298    }
1299
1300    /// Verify that selabel() overwrites pre-existing labels with the policy's
1301    /// labels, rather than accumulating or skipping them.
1302    #[test]
1303    fn selabel_replaces_stale_labels() {
1304        let file_contexts = indoc! {b"
1305            /(/.*)?		system_u:object_r:default_t:s0
1306            /usr(/.*)?		system_u:object_r:usr_t:s0
1307        "};
1308
1309        let fs_entries = "\
1310/usr 0 40755 2 0 0 0 0.0 - - - security.selinux=unconfined_u:object_r:container_file_t:s0:c0,c1
1311/usr/lib 0 40755 2 0 0 0 0.0 - - - security.selinux=unconfined_u:object_r:container_file_t:s0:c0,c1
1312/usr/lib/readme.txt 5 100644 1 0 0 0 0.0 - hello - security.selinux=unconfined_u:object_r:container_file_t:s0:c0,c1
1313";
1314        let mut fs = build_fs_with_selinux(file_contexts, &[], fs_entries);
1315        let test_repo = TestRepo::<Sha256HashValue>::new();
1316
1317        assert!(selabel(&mut fs, &test_repo.repo).unwrap());
1318
1319        assert_eq!(
1320            get_label(&fs, "/usr").unwrap(),
1321            "system_u:object_r:usr_t:s0"
1322        );
1323        assert_eq!(
1324            get_label(&fs, "/usr/lib").unwrap(),
1325            "system_u:object_r:usr_t:s0"
1326        );
1327        assert_eq!(
1328            get_label(&fs, "/usr/lib/readme.txt").unwrap(),
1329            "system_u:object_r:usr_t:s0"
1330        );
1331    }
1332
1333    /// Verify that all three match strategies produce identical results
1334    /// for a representative set of patterns and paths.
1335    #[test]
1336    fn matcher_strategies_agree() {
1337        let file_contexts = indoc! {b"
1338            /\t\tsystem_u:object_r:root_t:s0
1339            /usr\t\tsystem_u:object_r:usr_t:s0
1340            /usr/bin(/.*)?\t\tsystem_u:object_r:bin_t:s0
1341            /etc(/.*)?\t\tsystem_u:object_r:etc_t:s0
1342            /var(/.*)?  -d  system_u:object_r:var_dir_t:s0
1343            /var(/.*)?  --  system_u:object_r:var_file_t:s0
1344            /tmp(/.*)?  <<none>>
1345        "};
1346
1347        let mut regexps = vec![];
1348        let mut contexts = vec![];
1349        process_spec_file(file_contexts.as_slice(), &mut regexps, &mut contexts).unwrap();
1350        regexps.reverse();
1351        contexts.reverse();
1352
1353        let mut matchers: Vec<(MatchStrategy, Matcher)> = [
1354            MatchStrategy::Dfa,
1355            MatchStrategy::Pcre,
1356            MatchStrategy::Hybrid,
1357        ]
1358        .into_iter()
1359        .map(|s| (s, Matcher::build_with_strategy(s, &regexps).unwrap()))
1360        .collect();
1361
1362        // Paths to test, paired with the file-type byte.
1363        let test_cases: &[(&[u8], u8)] = &[
1364            (b"/", b'd'),
1365            (b"/usr", b'd'),
1366            (b"/usr/bin", b'd'),
1367            (b"/usr/bin/hello", b'-'),
1368            (b"/etc", b'd'),
1369            (b"/etc/hostname", b'-'),
1370            (b"/var", b'd'),
1371            (b"/var/log", b'd'),
1372            (b"/var/spool/mail", b'-'),
1373            (b"/tmp", b'd'),
1374            (b"/tmp/scratch", b'-'),
1375            (b"/nonexistent", b'-'),
1376        ];
1377
1378        // Collect results from first strategy, then assert all others match.
1379        let expected: Vec<_> = {
1380            let (_, m) = &mut matchers[0];
1381            test_cases
1382                .iter()
1383                .map(|(path, ifmt)| {
1384                    let key = [*path, &[*ifmt]].concat();
1385                    m.lookup(&key)
1386                })
1387                .collect()
1388        };
1389
1390        for (strategy, m) in &mut matchers[1..] {
1391            for (i, (path, ifmt)) in test_cases.iter().enumerate() {
1392                let key = [*path, &[*ifmt]].concat();
1393                let result = m.lookup(&key);
1394                assert_eq!(
1395                    result,
1396                    expected[i],
1397                    "{strategy:?} disagrees with {:?} on path {:?} (ifmt={ifmt:?}): \
1398                     got {result:?}, expected {:?}",
1399                    MatchStrategy::Dfa,
1400                    String::from_utf8_lossy(path),
1401                    expected[i],
1402                );
1403            }
1404        }
1405    }
1406
1407    /// Verify that hybrid lookup with PCRE2 lookaround patterns interleaved
1408    /// among DFA patterns produces the same results as pure PCRE2, and that
1409    /// the priority interleaving is correct (a PCRE2 pattern at a lower index
1410    /// must beat a DFA match at a higher index, and vice versa).
1411    #[test]
1412    fn hybrid_lookaround_priority() {
1413        // Patterns are listed in file_contexts order (low-to-high priority).
1414        // After reversing, index 0 = highest priority.
1415        //
1416        // We mix in negative-lookahead patterns (PCRE2-only) at various
1417        // positions so the hybrid matcher must correctly interleave DFA
1418        // and PCRE2 results.
1419        let patterns: &[(&str, &str)] = &[
1420            // Low priority (will be at high indices after reverse)
1421            (r"/(.*)?", "system_u:object_r:default_t:s0"),
1422            (r"/usr(.*)?", "system_u:object_r:usr_t:s0"),
1423            // PCRE2-only: lookahead (mid priority)
1424            (r"/usr/bin/(?!bad).*", "system_u:object_r:bin_ok_t:s0"),
1425            // DFA-compatible (mid priority, higher than above)
1426            (r"/usr/bin/good", "system_u:object_r:bin_good_t:s0"),
1427            // PCRE2-only: lookahead (high priority)
1428            (r"/etc/(?!shadow).*", "system_u:object_r:etc_public_t:s0"),
1429            // DFA-compatible (highest priority)
1430            (r"/etc/hostname", "system_u:object_r:hostname_t:s0"),
1431        ];
1432
1433        let mut regexps: Vec<String> = patterns
1434            .iter()
1435            .map(|(re, _)| format!("^({re}).$"))
1436            .collect();
1437        let mut contexts: Vec<String> = patterns.iter().map(|(_, ctx)| ctx.to_string()).collect();
1438        regexps.reverse();
1439        contexts.reverse();
1440
1441        let mut pcre = Matcher::build_with_strategy(MatchStrategy::Pcre, &regexps).unwrap();
1442        let mut hybrid = Matcher::build_with_strategy(MatchStrategy::Hybrid, &regexps).unwrap();
1443
1444        let test_cases: &[(&[u8], u8, &str)] = &[
1445            // /etc/hostname: DFA pattern at index 0 (highest priority) wins.
1446            (b"/etc/hostname", b'-', "system_u:object_r:hostname_t:s0"),
1447            // /etc/passwd: PCRE2 lookahead at index 1 matches (not "shadow").
1448            (b"/etc/passwd", b'-', "system_u:object_r:etc_public_t:s0"),
1449            // /etc/shadow: PCRE2 lookahead at index 1 does NOT match,
1450            // falls through to DFA default_t.
1451            (b"/etc/shadow", b'-', "system_u:object_r:default_t:s0"),
1452            // /usr/bin/good: DFA pattern at index 2 wins over PCRE2 at index 3.
1453            (b"/usr/bin/good", b'-', "system_u:object_r:bin_good_t:s0"),
1454            // /usr/bin/hello: PCRE2 lookahead at index 3 matches (not "bad").
1455            (b"/usr/bin/hello", b'-', "system_u:object_r:bin_ok_t:s0"),
1456            // /usr/bin/bad: PCRE2 lookahead at index 3 does NOT match,
1457            // falls through to DFA usr_t.
1458            (b"/usr/bin/bad", b'-', "system_u:object_r:usr_t:s0"),
1459        ];
1460
1461        for (path, ifmt, expected) in test_cases {
1462            let key = [*path, &[*ifmt]].concat();
1463            let path_str = String::from_utf8_lossy(path);
1464
1465            let pcre_idx = pcre.lookup(&key);
1466            let hybrid_idx = hybrid.lookup(&key);
1467
1468            assert_eq!(
1469                pcre_idx, hybrid_idx,
1470                "hybrid disagrees with pcre2 on {path_str}: \
1471                 pcre2={pcre_idx:?} hybrid={hybrid_idx:?}"
1472            );
1473
1474            let label = pcre_idx.map(|i| contexts[i].as_str());
1475            assert_eq!(
1476                label,
1477                Some(*expected),
1478                "{path_str}: expected {expected}, got {label:?}"
1479            );
1480        }
1481    }
1482
1483    mod proptest_matcher {
1484        use super::*;
1485        use proptest::prelude::*;
1486        use proptest::strategy::ValueTree;
1487
1488        /// A path segment: 1–8 lowercase ASCII characters.
1489        fn path_segment() -> impl Strategy<Value = String> {
1490            "[a-z][a-z0-9_]{0,7}"
1491        }
1492
1493        /// An absolute directory path with 1–4 segments.
1494        fn dir_path() -> impl Strategy<Value = String> {
1495            proptest::collection::vec(path_segment(), 1..=4)
1496                .prop_map(|segs| format!("/{}", segs.join("/")))
1497        }
1498
1499        /// A filename (no slashes).
1500        fn filename() -> impl Strategy<Value = String> {
1501            "[a-z][a-z0-9_.]{0,11}"
1502        }
1503
1504        /// One SELinux-like pattern+context, in pre-`process_spec_file` format
1505        /// (i.e. the anchored `^(...).$` regex).
1506        #[derive(Debug, Clone)]
1507        enum PatternKind {
1508            /// Exact file: `^(/dir/file).$`
1509            Exact(String),
1510            /// Directory wildcard: `^(/dir(/.*)?).$`
1511            DirWild(String),
1512            /// Negative lookahead (PCRE2-only): `^(/dir/(?!name).*).$`
1513            Lookahead(String, String),
1514        }
1515
1516        fn pattern_kind() -> impl Strategy<Value = PatternKind> {
1517            prop_oneof![
1518                8 => (dir_path(), filename()).prop_map(|(d, f)| PatternKind::Exact(
1519                    format!("{d}/{f}")
1520                )),
1521                4 => dir_path().prop_map(PatternKind::DirWild),
1522                1 => (dir_path(), filename()).prop_map(|(d, f)| PatternKind::Lookahead(d, f)),
1523            ]
1524        }
1525
1526        impl PatternKind {
1527            fn to_regexp(&self) -> String {
1528                match self {
1529                    PatternKind::Exact(path) => format!("^({path}).$"),
1530                    PatternKind::DirWild(dir) => format!("^({dir}(/.*)?).$"),
1531                    PatternKind::Lookahead(dir, excluded) => {
1532                        format!("^({dir}/(?!{excluded}).*).$")
1533                    }
1534                }
1535            }
1536
1537            fn context(&self, idx: usize) -> String {
1538                format!("system_u:object_r:rule{idx}_t:s0")
1539            }
1540        }
1541
1542        /// Generate test paths that have a chance of matching the patterns.
1543        fn test_path(dirs: &[String]) -> impl Strategy<Value = Vec<u8>> + '_ {
1544            let ifmt = prop_oneof![Just(b'-'), Just(b'd'), Just(b'l'),];
1545
1546            let path = prop_oneof![
1547                // Path under one of the generated directories.
1548                (proptest::sample::select(dirs.to_vec()), filename())
1549                    .prop_map(|(d, f)| format!("{d}/{f}")),
1550                // Just a directory itself.
1551                proptest::sample::select(dirs.to_vec()),
1552                // Random path unlikely to match anything specific.
1553                dir_path(),
1554            ];
1555
1556            (path, ifmt).prop_map(|(p, i)| [p.as_bytes(), &[i]].concat())
1557        }
1558
1559        proptest! {
1560            #![proptest_config(ProptestConfig::with_cases(20))]
1561
1562            /// Property: for any generated set of patterns (including some
1563            /// with lookarounds) and paths, PCRE2 and Hybrid matchers must
1564            /// produce identical lookup results.
1565            #[test]
1566            fn hybrid_matches_pcre2(
1567                patterns in proptest::collection::vec(pattern_kind(), 20..=200),
1568                path_count in 50..=200usize,
1569            ) {
1570                // Build regexp + context lists.
1571                let mut regexps = Vec::new();
1572                let mut contexts = Vec::new();
1573
1574                // Catch-all at lowest priority.
1575                regexps.push("^(/(.*)?).$$".to_string());
1576                contexts.push("system_u:object_r:default_t:s0".to_string());
1577
1578                for (i, pat) in patterns.iter().enumerate() {
1579                    regexps.push(pat.to_regexp());
1580                    contexts.push(pat.context(i));
1581                }
1582
1583                regexps.reverse();
1584                contexts.reverse();
1585
1586                let mut pcre = Matcher::build_with_strategy(
1587                    MatchStrategy::Pcre, &regexps,
1588                ).unwrap();
1589                let mut hybrid = Matcher::build(&regexps).unwrap();
1590
1591                // Collect directory paths used in patterns for targeted path generation.
1592                let dirs: Vec<String> = patterns.iter().filter_map(|p| match p {
1593                    PatternKind::Exact(path) => {
1594                        path.rsplit_once('/').map(|(d, _)| d.to_string())
1595                    }
1596                    PatternKind::DirWild(d) | PatternKind::Lookahead(d, _) => {
1597                        Some(d.clone())
1598                    }
1599                }).collect();
1600
1601                // Ensure we have at least one directory for sampling.
1602                let dirs = if dirs.is_empty() {
1603                    vec!["/fallback".to_string()]
1604                } else {
1605                    dirs
1606                };
1607
1608                let path_strategy = proptest::collection::vec(
1609                    test_path(&dirs), path_count,
1610                );
1611                let mut runner = proptest::test_runner::TestRunner::new(
1612                    ProptestConfig::default(),
1613                );
1614                let paths = path_strategy
1615                    .new_tree(&mut runner)
1616                    .unwrap()
1617                    .current();
1618
1619                for key in &paths {
1620                    let pcre_result = pcre.lookup(key);
1621                    let hybrid_result = hybrid.lookup(key);
1622                    prop_assert_eq!(
1623                        pcre_result, hybrid_result,
1624                        "disagreement on {:?}",
1625                        String::from_utf8_lossy(key),
1626                    );
1627                }
1628            }
1629        }
1630    }
1631}