Skip to main content

bootc_lib/
lsm.rs

1use std::borrow::Cow;
2#[cfg(feature = "selinux")]
3use std::ffi::CString;
4use std::io::Write;
5use std::os::fd::AsRawFd;
6use std::os::unix::process::CommandExt;
7use std::path::Path;
8use std::process::Command;
9
10use anyhow::{Context, Result};
11use bootc_utils::CommandRunExt;
12use camino::{Utf8Path, Utf8PathBuf};
13use cap_std::fs::Dir;
14use cap_std::fs::{DirBuilder, OpenOptions};
15use cap_std::io_lifetimes::AsFilelike;
16use cap_std_ext::cap_std;
17use cap_std_ext::cap_std::fs::{Metadata, MetadataExt};
18use cap_std_ext::dirext::CapStdExtDirExt;
19use fn_error_context::context;
20use ostree_ext::gio;
21use ostree_ext::ostree;
22use rustix::fd::AsFd;
23
24/// The mount path for selinux
25const SELINUXFS: &str = "/sys/fs/selinux";
26/// The SELinux xattr
27const SELINUX_XATTR: &[u8] = b"security.selinux\0";
28/// The kernel initial SID used for objects without a label.
29#[cfg(feature = "selinux")]
30const SELINUX_INITIAL_SID_UNLABELED: &str = "unlabeled";
31const SELF_CURRENT: &str = "/proc/self/attr/current";
32
33/// Whether SELinux is enabled for the current process.
34///
35/// libselinux caches this state process-wide, so mounting selinuxfs requires a
36/// re-exec before this result can change.
37#[cfg(feature = "selinux")]
38pub(crate) fn selinux_enabled() -> bool {
39    selinux::kernel_support() != selinux::KernelSupport::Unsupported
40}
41
42/// Without the `selinux` feature, bootc treats SELinux as disabled.
43#[cfg(not(feature = "selinux"))]
44pub(crate) fn selinux_enabled() -> bool {
45    false
46}
47
48/// Whether SELinux is enabled on the host, as observed from PID 1's mount namespace.
49///
50/// This bypasses libselinux's process-cached state and is only appropriate while
51/// bootstrapping the selinuxfs mount before re-exec.
52#[cfg(feature = "selinux")]
53#[context("Querying host SELinux availability")]
54pub(crate) fn host_selinux_enabled() -> Result<bool> {
55    Path::new("/proc/1/root/sys/fs/selinux/enforce")
56        .try_exists()
57        .map_err(Into::into)
58}
59
60/// Without the `selinux` feature, bootc treats SELinux as disabled.
61#[cfg(not(feature = "selinux"))]
62pub(crate) fn host_selinux_enabled() -> Result<bool> {
63    Ok(false)
64}
65
66/// Get the current process SELinux security context
67fn get_current_security_context() -> Result<String> {
68    std::fs::read_to_string(SELF_CURRENT).with_context(|| format!("Reading {SELF_CURRENT}"))
69}
70
71/// Check if the current process has the capability to write SELinux security
72/// contexts unknown to the current policy. In SELinux terms this capability is
73/// gated under `mac_admin` (admin control over SELinux state), and in the Fedora
74/// policy at least it's part of `install_t`.
75#[context("Testing install_t")]
76fn test_install_t() -> Result<bool> {
77    let tmpf = tempfile::NamedTempFile::new()?;
78    // Our implementation here writes a label which is always unknown to the current policy
79    // to verify that we have the capability to do so.
80    let st = Command::new("chcon")
81        .args(["-t", "invalid_bootcinstall_testlabel_t"])
82        .arg(tmpf.path())
83        .stderr(std::process::Stdio::null())
84        .status()?;
85    Ok(st.success())
86}
87
88/// Ensure that the current process has the capability to write SELinux security
89/// contexts unknown to the current policy.
90///
91/// See [`test_install_t`] above for how we check for that capability.
92///
93/// In the general case of both upgrade or install, we may e.g. jump major versions
94/// or even operating systems, and we need the ability to write arbitrary labels.
95/// If the current process doesn't already have `mac_admin/install_t` then we
96/// make a new temporary copy of our binary, and give it the same label as /usr/bin/ostree,
97/// which in Fedora derivatives at least was already historically labeled with
98/// the correct install_t label.
99///
100/// However, if you maintain a bootc operating system with SELinux, you should from
101/// the start ensure that /usr/bin/bootc has the correct capabilities.
102#[context("Ensuring selinux install_t type")]
103pub(crate) fn selinux_ensure_install(reexec_env: &[(&str, &str)]) -> Result<bool> {
104    let guardenv = "_bootc_selinuxfs_mounted";
105    let current = get_current_security_context()?;
106    tracing::debug!("Current security context is {current}");
107    if let Some(p) = std::env::var_os(guardenv) {
108        let p = Path::new(&p);
109        if p.exists() {
110            tracing::debug!("Removing temporary file");
111            std::fs::remove_file(p).context("Removing {p:?}")?;
112        } else {
113            tracing::debug!("Assuming we now have a privileged (e.g. install_t) label");
114        }
115        return test_install_t();
116    }
117    if test_install_t()? {
118        tracing::debug!("We have install_t");
119        return Ok(true);
120    }
121    tracing::debug!("Lacking install_t capabilities; copying self to temporary file for re-exec");
122    // OK now, we always copy our binary to a tempfile, set its security context
123    // to match that of /usr/bin/ostree, and then re-exec.  This is really a gross
124    // hack; we can't always rely on https://github.com/fedora-selinux/selinux-policy/pull/1500/commits/67eb283c46d35a722636d749e5b339615fe5e7f5
125    let mut tmpf = tempfile::NamedTempFile::new()?;
126    let srcpath = std::env::current_exe()?;
127    let mut src = std::fs::File::open(&srcpath)?;
128    let meta = src.metadata()?;
129    std::io::copy(&mut src, &mut tmpf).context("Copying self to tempfile for selinux re-exec")?;
130    tmpf.as_file_mut()
131        .set_permissions(meta.permissions())
132        .context("Setting permissions of tempfile")?;
133    let container_root = Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
134    let policy = ostree::SePolicy::new_at(container_root.as_raw_fd(), gio::Cancellable::NONE)?;
135    let label = require_label(&policy, "/usr/bin/ostree".into(), libc::S_IFREG | 0o755)?;
136    set_security_selinux(tmpf.as_fd(), label.as_bytes())?;
137    let tmpf: Utf8PathBuf = tmpf.keep()?.1.try_into().unwrap();
138    tracing::debug!("Created {tmpf:?}");
139
140    let mut cmd = Command::new(&tmpf);
141    cmd.env(guardenv, tmpf);
142    cmd.env(bootc_utils::reexec::ORIG, srcpath);
143    bootc_utils::reexec::prepare_reexec(&mut cmd, reexec_env);
144    cmd.log_debug();
145    Err(anyhow::Error::msg(cmd.exec()).context("execve"))
146}
147
148/// Query whether SELinux is apparently enabled in the target root
149pub(crate) fn have_selinux_policy(root: &Dir) -> Result<bool> {
150    // TODO use ostree::SePolicy and query policy name
151    root.try_exists("etc/selinux/config").map_err(Into::into)
152}
153
154/// A type which will reset SELinux back to enforcing mode when dropped.
155/// This is a workaround for the deep difficulties in trying to reliably
156/// gain the `mac_admin` permission (install_t).
157#[must_use]
158#[derive(Debug)]
159#[allow(dead_code)]
160pub(crate) struct SetEnforceGuard(Option<()>);
161
162impl SetEnforceGuard {
163    pub(crate) fn new() -> Self {
164        SetEnforceGuard(Some(()))
165    }
166
167    #[allow(dead_code)]
168    pub(crate) fn consume(mut self) -> Result<()> {
169        // SAFETY: The option cannot have been consumed until now
170        self.0.take().unwrap();
171        // This returns errors
172        selinux_set_permissive(false)
173    }
174}
175
176impl Drop for SetEnforceGuard {
177    fn drop(&mut self) {
178        // A best-effort attempt to re-enable enforcement on drop (installation failure)
179        if let Some(()) = self.0.take() {
180            let _ = selinux_set_permissive(false);
181        }
182    }
183}
184
185/// Try to enter the install_t domain, but if we can't do that, then
186/// just setenforce 0.
187#[context("Ensuring selinux install_t type")]
188pub(crate) fn selinux_ensure_install_or_setenforce(
189    reexec_env: &[(&str, &str)],
190) -> Result<Option<SetEnforceGuard>> {
191    // If the process already has install_t, exit early
192    // Note that this may re-exec the entire process
193    if selinux_ensure_install(reexec_env)? {
194        return Ok(None);
195    }
196    let g = if std::env::var_os("BOOTC_SETENFORCE0_FALLBACK").is_some() {
197        tracing::warn!("Failed to enter install_t; temporarily setting permissive mode");
198        selinux_set_permissive(true)?;
199        Some(SetEnforceGuard::new())
200    } else {
201        let current = get_current_security_context()?;
202        anyhow::bail!(
203            "Failed to enter install_t (running as {current}) - use BOOTC_SETENFORCE0_FALLBACK=1 to override"
204        );
205    };
206    Ok(g)
207}
208
209/// A thin wrapper for loading a SELinux policy that maps "policy nonexistent" to None.
210pub(crate) fn new_sepolicy_at(fd: impl AsFd) -> Result<Option<ostree::SePolicy>> {
211    let fd = fd.as_fd();
212    let cancellable = gio::Cancellable::NONE;
213    let sepolicy = ostree::SePolicy::new_at(fd.as_raw_fd(), cancellable)?;
214    let r = if sepolicy.csum().is_none() {
215        None
216    } else {
217        Some(sepolicy)
218    };
219    Ok(r)
220}
221
222#[context("Setting SELinux permissive mode")]
223#[allow(dead_code)]
224pub(crate) fn selinux_set_permissive(permissive: bool) -> Result<()> {
225    let enforce_path = &Utf8Path::new(SELINUXFS).join("enforce");
226    if !enforce_path.exists() {
227        return Ok(());
228    }
229    let mut f = std::fs::File::options().write(true).open(enforce_path)?;
230    f.write_all(if permissive { b"0" } else { b"1" })?;
231    tracing::debug!(
232        "Set SELinux mode: {}",
233        if permissive {
234            "permissive"
235        } else {
236            "enforcing"
237        }
238    );
239    Ok(())
240}
241
242/// Check if the ostree-formatted extended attributes include a security.selinux value.
243pub(crate) fn xattrs_have_selinux(xattrs: &ostree::glib::Variant) -> bool {
244    let n = xattrs.n_children();
245    for i in 0..n {
246        let child = xattrs.child_value(i);
247        let key = child.child_value(0);
248        let key = key.data_as_bytes();
249        if key == SELINUX_XATTR {
250            return true;
251        }
252    }
253    false
254}
255
256/// Look up the label for a path in a policy, and error if one is not found.
257pub(crate) fn require_label(
258    policy: &ostree::SePolicy,
259    destname: &Utf8Path,
260    mode: u32,
261) -> Result<ostree::glib::GString> {
262    policy
263        .label(destname.as_str(), mode, ostree::gio::Cancellable::NONE)?
264        .ok_or_else(|| {
265            anyhow::anyhow!(
266                "No label found in policy '{:?}' for {destname})",
267                policy.csum()
268            )
269        })
270}
271
272/// Look up the label for a path in a policy, returning None if no match is found.
273pub(crate) fn optional_label(
274    policy: &ostree::SePolicy,
275    destname: &Utf8Path,
276    mode: u32,
277) -> Result<Option<ostree::glib::GString>> {
278    policy
279        .label(destname.as_str(), mode, ostree::gio::Cancellable::NONE)
280        .map_err(Into::into)
281}
282
283/// A thin wrapper for invoking fsetxattr(security.selinux)
284pub(crate) fn set_security_selinux(fd: std::os::fd::BorrowedFd, label: &[u8]) -> Result<()> {
285    rustix::fs::fsetxattr(
286        fd,
287        "security.selinux",
288        label,
289        rustix::fs::XattrFlags::empty(),
290    )
291    .context("fsetxattr(security.selinux)")
292}
293
294/// The labeling state; "unsupported" is distinct as we need to handle
295/// cases like the ESP which don't support labeling.
296#[derive(Debug, PartialEq, Eq)]
297pub(crate) enum SELinuxLabelState {
298    Unlabeled,
299    Unsupported,
300    Labeled,
301}
302
303/// The type of the kernel's `unlabeled` initial context. Files with it (e.g.
304/// created while SELinux was disabled) need relabeling just like files without
305/// any label.
306#[cfg(feature = "selinux")]
307#[derive(Debug)]
308struct UnlabeledType(CString);
309
310/// Without the `selinux` feature, bootc never parses SELinux contexts, so this
311/// can't be constructed and only files without any label count as unlabeled.
312#[cfg(not(feature = "selinux"))]
313#[derive(Debug)]
314enum UnlabeledType {}
315
316impl UnlabeledType {
317    /// Look up the unlabeled type if SELinux is enabled for this process.
318    #[cfg(feature = "selinux")]
319    fn query() -> Result<Option<Self>> {
320        if !selinux_enabled() {
321            return Ok(None);
322        }
323        let context = selinux::SecurityContext::of_initial_kernel_context(
324            SELINUX_INITIAL_SID_UNLABELED,
325            true,
326        )
327        .context("Querying the SELinux unlabeled initial context")?;
328        context_type(context.as_bytes()).map(|t| Some(Self(t)))
329    }
330
331    #[cfg(not(feature = "selinux"))]
332    fn query() -> Result<Option<Self>> {
333        Ok(None)
334    }
335
336    /// Whether a `security.selinux` value has this type.
337    #[cfg(feature = "selinux")]
338    fn matches(&self, context: &[u8]) -> Result<bool> {
339        Ok(context_type(context)?.as_c_str() == self.0.as_c_str())
340    }
341
342    #[cfg(not(feature = "selinux"))]
343    fn matches(&self, _context: &[u8]) -> Result<bool> {
344        match *self {}
345    }
346}
347
348#[cfg(feature = "selinux")]
349fn context_type(context: &[u8]) -> Result<CString> {
350    // security.selinux xattrs may include a trailing NUL terminator.
351    let context = context.strip_suffix(b"\0").unwrap_or(context);
352    let context = CString::new(context).context("SELinux context contains an interior NUL")?;
353    context
354        .to_str()
355        .context("SELinux context is not valid UTF-8")?;
356    selinux::OpaqueSecurityContext::from_c_str(&context)
357        .context("Parsing SELinux context")?
358        .the_type()
359        .context("Reading SELinux context type")
360}
361
362/// Query the SELinux labeling for a particular path
363#[cfg(feature = "selinux")]
364pub(crate) fn has_security_selinux(root: &Dir, path: &Utf8Path) -> Result<SELinuxLabelState> {
365    let unlabeled_type = UnlabeledType::query()?.context("SELinux is not enabled")?;
366    has_security_selinux_inner(root, path, Some(&unlabeled_type))
367}
368
369fn has_security_selinux_inner(
370    root: &Dir,
371    path: &Utf8Path,
372    unlabeled_type: Option<&UnlabeledType>,
373) -> Result<SELinuxLabelState> {
374    // TODO: avoid hardcoding a max size here
375    let mut buf = [0u8; 2048];
376    let fdpath = format!("/proc/self/fd/{}/{path}", root.as_raw_fd());
377    match rustix::fs::lgetxattr(fdpath, "security.selinux", &mut buf) {
378        Ok(len) => {
379            // Installation can relabel a target while its host has SELinux disabled.
380            // In that case, retain the conservative xattr-presence behavior.
381            let Some(unlabeled_type) = unlabeled_type else {
382                return Ok(SELinuxLabelState::Labeled);
383            };
384            let unlabeled = unlabeled_type
385                .matches(&buf[..len])
386                .with_context(|| format!("Parsing SELinux context for {path:?}"))?;
387            if unlabeled {
388                Ok(SELinuxLabelState::Unlabeled)
389            } else {
390                Ok(SELinuxLabelState::Labeled)
391            }
392        }
393        Err(rustix::io::Errno::OPNOTSUPP) => Ok(SELinuxLabelState::Unsupported),
394        Err(rustix::io::Errno::NODATA) => Ok(SELinuxLabelState::Unlabeled),
395        Err(e) => Err(e).with_context(|| format!("Failed to look up context for {path:?}")),
396    }
397}
398
399/// Directly set the `security.selinux` extended attribute on the target
400/// path. Symbolic links are not followed for the target.
401///
402/// Note that this API will work even if SELinux is disabled.
403pub(crate) fn set_security_selinux_path(root: &Dir, path: &Utf8Path, label: &[u8]) -> Result<()> {
404    let fdpath = format!("/proc/self/fd/{}/", root.as_raw_fd());
405    let fdpath = &Path::new(&fdpath).join(path);
406    rustix::fs::lsetxattr(
407        fdpath,
408        "security.selinux",
409        label,
410        rustix::fs::XattrFlags::empty(),
411    )?;
412    Ok(())
413}
414
415fn ensure_labeled_inner(
416    root: &Dir,
417    path: &Utf8Path,
418    metadata: &Metadata,
419    policy: &ostree::SePolicy,
420    unlabeled_type: Option<&UnlabeledType>,
421) -> Result<SELinuxLabelState> {
422    let r = has_security_selinux_inner(root, path, unlabeled_type)?;
423    if matches!(r, SELinuxLabelState::Unlabeled) {
424        relabel(root, metadata, path, None, policy)?;
425    }
426    Ok(r)
427}
428
429/// Given the policy, relabel the target file or directory.
430/// Optionally, an override for the path can be provided
431/// to set the label as if the target has that filename.
432pub(crate) fn relabel(
433    root: &Dir,
434    metadata: &Metadata,
435    path: &Utf8Path,
436    as_path: Option<&Utf8Path>,
437    policy: &ostree::SePolicy,
438) -> Result<()> {
439    assert!(!path.starts_with("/"));
440    let as_path = as_path
441        .map(Cow::Borrowed)
442        .unwrap_or_else(|| Utf8Path::new("/").join(path).into());
443    let label = require_label(policy, &as_path, metadata.mode())?;
444    tracing::trace!("Setting label for {path} to {label}");
445    set_security_selinux_path(root, &path, label.as_bytes())
446}
447
448pub(crate) fn relabel_recurse_inner(
449    root: &Dir,
450    path: &mut Utf8PathBuf,
451    mut as_path: Option<&mut Utf8PathBuf>,
452    policy: &ostree::SePolicy,
453) -> Result<()> {
454    // Relabel this directory
455    let self_meta = root.dir_metadata()?;
456    relabel(
457        root,
458        &self_meta,
459        path,
460        as_path.as_ref().map(|p| p.as_path()),
461        policy,
462    )?;
463
464    // Relabel all children
465    for ent in root.read_dir(&path)? {
466        let ent = ent?;
467        let metadata = ent.metadata()?;
468        let name = ent.file_name();
469        let name = name
470            .to_str()
471            .ok_or_else(|| anyhow::anyhow!("Invalid non-UTF-8 filename: {name:?}"))?;
472        // Extend both copies of the path
473        path.push(name);
474        if let Some(p) = as_path.as_mut() {
475            p.push(name);
476        }
477
478        if metadata.is_dir() {
479            let as_path = as_path.as_deref_mut();
480            relabel_recurse_inner(root, path, as_path, policy)?;
481        } else {
482            let as_path = as_path.as_ref().map(|p| p.as_path());
483            relabel(root, &metadata, &path, as_path, policy)?
484        }
485        // Trim what we added to the path
486        let r = path.pop();
487        assert!(r);
488        if let Some(p) = as_path.as_mut() {
489            let r = p.pop();
490            assert!(r);
491        }
492    }
493
494    Ok(())
495}
496
497/// Recursively relabel the target directory.
498pub(crate) fn relabel_recurse(
499    root: &Dir,
500    path: impl AsRef<Utf8Path>,
501    as_path: Option<&Utf8Path>,
502    policy: &ostree::SePolicy,
503) -> Result<()> {
504    let mut path = path.as_ref().to_owned();
505    // This path must be relative, as we access via cap-std
506    assert!(!path.starts_with("/"));
507    let mut as_path = as_path.map(|v| v.to_owned());
508    // But the as_path must be absolute, if provided
509    if let Some(as_path) = as_path.as_deref() {
510        assert!(as_path.starts_with("/"));
511    }
512    relabel_recurse_inner(root, &mut path, as_path.as_mut(), policy)
513}
514
515/// Recursively ensure all files under a directory have SELinux labels.
516/// Uses the `walk` API with `noxdev` and `skip_mountpoints` to avoid crossing
517/// mount point boundaries
518/// (e.g. into sysfs, procfs, etc.).
519/// The provided `skip` parameter is a device/inode pair that we will ignore
520/// (and not traverse into).
521pub(crate) fn ensure_dir_labeled_recurse(
522    root: &Dir,
523    path: &mut Utf8PathBuf,
524    policy: &ostree::SePolicy,
525    skip: Option<(libc::dev_t, libc::ino64_t)>,
526) -> Result<()> {
527    use cap_std_ext::dirext::WalkConfiguration;
528    use std::ops::ControlFlow;
529
530    // When SELinux is unavailable to this process, retain the conservative
531    // xattr-presence behavior in `has_security_selinux_inner`.
532    let unlabeled_type = UnlabeledType::query()?;
533    // Juggle the cap-std requirement for relative paths vs the libselinux
534    // requirement for absolute paths by special casing the empty string "" as "."
535    // just for the initial directory enumeration.
536    let path_for_read = if path.as_str().is_empty() {
537        Utf8Path::new(".")
538    } else {
539        &*path
540    };
541
542    let mut n = 0u64;
543
544    // Label the starting directory itself; the walk API only visits children.
545    let metadata = root.symlink_metadata(path_for_read)?;
546    match ensure_labeled_inner(root, path, &metadata, policy, unlabeled_type.as_ref())? {
547        SELinuxLabelState::Unlabeled => {
548            n += 1;
549        }
550        SELinuxLabelState::Unsupported => return Ok(()),
551        SELinuxLabelState::Labeled => {}
552    }
553    let config = WalkConfiguration::default()
554        .noxdev()
555        .skip_mountpoints()
556        .path_base(path_for_read.as_std_path());
557
558    root.open_dir(path_for_read)?
559        .walk::<_, anyhow::Error>(&config, |component| {
560            let metadata = component.entry.metadata()?;
561
562            // Check if this entry should be skipped
563            if let Some((skip_dev, skip_ino)) = skip {
564                if (metadata.dev(), metadata.ino()) == (skip_dev, skip_ino) {
565                    tracing::debug!("Skipping dev={skip_dev} inode={skip_ino}");
566                    // For directories, Break skips traversal into the directory
567                    // but continues with the next sibling. For non-directories,
568                    // Break would skip all remaining siblings, so use Continue
569                    // to skip only this entry.
570                    if component.file_type.is_dir() {
571                        return Ok(ControlFlow::Break(()));
572                    } else {
573                        return Ok(ControlFlow::Continue(()));
574                    }
575                }
576            }
577
578            let path = Utf8Path::from_path(component.path)
579                .ok_or_else(|| anyhow::anyhow!("Invalid non-UTF-8 path: {:?}", component.path))?;
580
581            match ensure_labeled_inner(root, path, &metadata, policy, unlabeled_type.as_ref())? {
582                SELinuxLabelState::Unlabeled => {
583                    n += 1;
584                }
585                // We check for Unsupported on the starting directory above,
586                // and the walk uses noxdev + skip_mountpoints to stay on
587                // the same filesystem, so hitting Unsupported here is
588                // unexpected.
589                SELinuxLabelState::Unsupported => {
590                    anyhow::bail!(
591                        "Unexpected SELinuxLabelState::Unsupported during walk at {path}"
592                    );
593                }
594                SELinuxLabelState::Labeled => {}
595            }
596
597            Ok(ControlFlow::Continue(()))
598        })?;
599
600    if n > 0 {
601        tracing::debug!("Relabeled {n} objects in {path}");
602    }
603    Ok(())
604}
605
606/// A wrapper for creating a directory, also optionally setting a SELinux label.
607pub(crate) fn ensure_dir_labeled(
608    root: &Dir,
609    destname: impl AsRef<Utf8Path>,
610    as_path: Option<&Utf8Path>,
611    mode: rustix::fs::Mode,
612    policy: Option<&ostree::SePolicy>,
613) -> Result<()> {
614    use std::borrow::Cow;
615
616    let destname = destname.as_ref();
617    // Special case the empty string
618    let local_destname = if destname.as_str().is_empty() {
619        ".".into()
620    } else {
621        destname
622    };
623    tracing::debug!("Labeling {local_destname}");
624    let label = policy
625        .map(|policy| {
626            let as_path = as_path
627                .map(Cow::Borrowed)
628                .unwrap_or_else(|| Utf8Path::new("/").join(destname).into());
629            require_label(policy, &as_path, libc::S_IFDIR | mode.as_raw_mode())
630        })
631        .transpose()
632        .with_context(|| format!("Labeling {local_destname}"))?;
633    tracing::trace!("Label for {local_destname} is {label:?}");
634
635    root.ensure_dir_with(local_destname, &DirBuilder::new())
636        .with_context(|| format!("Opening {local_destname}"))?;
637    let dirfd = cap_std_ext::cap_primitives::fs::open(
638        &root.as_filelike_view(),
639        local_destname.as_std_path(),
640        OpenOptions::new().read(true),
641    )
642    .context("opendir")?;
643    let dirfd = dirfd.as_fd();
644    rustix::fs::fchmod(dirfd, mode).context("fchmod")?;
645    if let Some(label) = label {
646        set_security_selinux(dirfd, label.as_bytes())?;
647    }
648
649    Ok(())
650}
651
652/// A wrapper for atomically writing a file, also optionally setting a SELinux label.
653/// The label is looked up for `destname` as if `root` were `/`, so pass the
654/// directory that stands for the target's root, never a subdirectory of it.
655pub(crate) fn atomic_replace_labeled<F>(
656    root: &Dir,
657    destname: impl AsRef<Utf8Path>,
658    mode: rustix::fs::Mode,
659    policy: Option<&ostree::SePolicy>,
660    f: F,
661) -> Result<()>
662where
663    F: FnOnce(&mut std::io::BufWriter<cap_std_ext::cap_tempfile::TempFile>) -> Result<()>,
664{
665    let destname = destname.as_ref();
666    let label = policy
667        .map(|policy| {
668            let abs_destname = Utf8Path::new("/").join(destname);
669            require_label(policy, &abs_destname, libc::S_IFREG | mode.as_raw_mode())
670        })
671        .transpose()?;
672
673    root.atomic_replace_with(destname, |w| {
674        // Peel through the bufwriter to get the fd
675        let fd = w.get_mut();
676        let fd = fd.as_file_mut();
677        let fd = fd.as_fd();
678        // Apply the target mode bits
679        rustix::fs::fchmod(fd, mode).context("fchmod")?;
680        // If we have a label, apply it
681        if let Some(label) = label {
682            tracing::debug!("Setting label for {destname} to {label}");
683            set_security_selinux(fd, label.as_bytes())?;
684        } else {
685            tracing::debug!("No label for {destname}");
686        }
687        // Finally call the underlying writer function
688        f(w)
689    })
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695    use gio::glib::Variant;
696
697    #[test]
698    fn test_selinux_xattr() {
699        let notfound: &[&[(&[u8], &[u8])]] = &[&[], &[(b"foo", b"bar")]];
700        for case in notfound {
701            assert!(!xattrs_have_selinux(&Variant::from(case)));
702        }
703        let found: &[(&[u8], &[u8])] = &[(b"foo", b"bar"), (SELINUX_XATTR, b"foo_t")];
704        assert!(xattrs_have_selinux(&Variant::from(found)));
705    }
706
707    #[test]
708    #[cfg(feature = "selinux")]
709    fn test_unlabeled_type() {
710        // Only meaningful where SELinux is enabled
711        let Some(unlabeled_type) = UnlabeledType::query().unwrap() else {
712            return;
713        };
714        assert!(
715            unlabeled_type
716                .matches(b"system_u:object_r:unlabeled_t:s0\0")
717                .unwrap()
718        );
719        assert!(
720            !unlabeled_type
721                .matches(b"system_u:object_r:var_t:s0")
722                .unwrap()
723        );
724    }
725
726    #[test]
727    #[cfg(feature = "selinux")]
728    fn test_context_type() {
729        let cases: &[(&[u8], Option<&str>)] = &[
730            (b"system_u:object_r:unlabeled_t:s0", Some("unlabeled_t")),
731            (b"system_u:object_r:unlabeled_t:s0\0", Some("unlabeled_t")),
732            (
733                b"system_u:object_r:unlabeled_t:s0:c0.c1023",
734                Some("unlabeled_t"),
735            ),
736            (b"system_u:object_r:var_t:s0", Some("var_t")),
737            (b"system_u:object_r", None),
738            (b"system_u:object_r:var_t:s0\0\0", None),
739            (b"system_u:object_r:var_t\0:s0", None),
740            (b"system_u:object_r:unlabeled_t:\xff", None),
741        ];
742
743        for (context, expected_type) in cases {
744            assert_eq!(
745                context_type(context)
746                    .ok()
747                    .and_then(|context_type| context_type.into_string().ok())
748                    .as_deref(),
749                *expected_type
750            );
751        }
752    }
753}