Skip to main content

libfuse_fs/util/
whiteout.rs

1//! Whiteout format selection and helpers shared by overlayfs/unionfs.
2//!
3//! Two formats are supported:
4//!
5//! - [`WhiteoutFormat::CharDev`] — Linux overlayfs convention. A whiteout is a
6//!   character device with major/minor 0/0 created via `mknod`. Requires
7//!   `CAP_MKNOD` on Linux and root on macOS, so unprivileged macOS use needs
8//!   the OCI form below.
9//! - [`WhiteoutFormat::OciWhiteout`] — OCI image-spec convention. A whiteout is
10//!   an empty regular file named `.wh.<base>`; opaque-directory marker is the
11//!   special name `.wh..wh..opq`. Works without any elevated privileges.
12
13use std::ffi::{OsStr, OsString};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub enum WhiteoutFormat {
17    CharDev,
18    OciWhiteout,
19}
20
21impl Default for WhiteoutFormat {
22    /// macOS defaults to the OCI form because creating char devices via
23    /// `mknod` requires root; Linux keeps the kernel-overlayfs convention.
24    fn default() -> Self {
25        if cfg!(target_os = "macos") {
26            WhiteoutFormat::OciWhiteout
27        } else {
28            WhiteoutFormat::CharDev
29        }
30    }
31}
32
33/// File-name prefix that marks a whiteout in the OCI form.
34pub const OCI_WHITEOUT_PREFIX: &str = ".wh.";
35
36/// Special file name (also under the same prefix) that marks a directory as
37/// opaque in the OCI form.
38pub const OCI_OPAQUE_MARKER: &str = ".wh..wh..opq";
39
40/// Returns `true` if `name` matches the OCI whiteout form `.wh.<base>` and is
41/// **not** the opaque-directory marker.
42pub fn is_oci_whiteout_name(name: &OsStr) -> bool {
43    let bytes = name.as_encoded_bytes();
44    bytes.starts_with(OCI_WHITEOUT_PREFIX.as_bytes()) && bytes != OCI_OPAQUE_MARKER.as_bytes()
45}
46
47/// Returns `true` if `name` is the OCI opaque-directory marker.
48pub fn is_oci_opaque_marker(name: &OsStr) -> bool {
49    name.as_encoded_bytes() == OCI_OPAQUE_MARKER.as_bytes()
50}
51
52/// If `raw` is an OCI whiteout name `.wh.<base>`, return `<base>`; otherwise
53/// `None` (also `None` for the opaque marker).
54pub fn oci_whiteout_target(raw: &OsStr) -> Option<&OsStr> {
55    if !is_oci_whiteout_name(raw) {
56        return None;
57    }
58    let bytes = raw.as_encoded_bytes();
59    let target = &bytes[OCI_WHITEOUT_PREFIX.len()..];
60    // SAFETY: bytes came from a valid OsStr; trimming a known UTF-8 prefix
61    // leaves a valid OsStr encoding behind.
62    Some(unsafe { OsStr::from_encoded_bytes_unchecked(target) })
63}
64
65/// Build the OCI whiteout name `.wh.<base>` for the given target.
66pub fn oci_whiteout_name(base: &OsStr) -> OsString {
67    use std::os::unix::ffi::OsStringExt;
68    let mut bytes = OCI_WHITEOUT_PREFIX.as_bytes().to_vec();
69    bytes.extend_from_slice(base.as_encoded_bytes());
70    OsString::from_vec(bytes)
71}
72
73/// Returns `true` if the user is allowed to create a file with this name under
74/// the given format. Used to reject `.wh.*` names from user-facing operations
75/// when the OCI format is in use.
76pub fn is_user_creatable_name(format: WhiteoutFormat, name: &OsStr) -> bool {
77    match format {
78        WhiteoutFormat::CharDev => true,
79        WhiteoutFormat::OciWhiteout => {
80            let bytes = name.as_encoded_bytes();
81            !bytes.starts_with(OCI_WHITEOUT_PREFIX.as_bytes())
82        }
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn detects_whiteout_names() {
92        assert!(is_oci_whiteout_name(OsStr::new(".wh.foo")));
93        assert!(is_oci_whiteout_name(OsStr::new(".wh..hidden")));
94        assert!(!is_oci_whiteout_name(OsStr::new("foo")));
95        // Opaque marker is reported separately, not as a normal whiteout.
96        assert!(!is_oci_whiteout_name(OsStr::new(OCI_OPAQUE_MARKER)));
97    }
98
99    #[test]
100    fn detects_opaque_marker() {
101        assert!(is_oci_opaque_marker(OsStr::new(OCI_OPAQUE_MARKER)));
102        assert!(!is_oci_opaque_marker(OsStr::new(".wh.foo")));
103    }
104
105    #[test]
106    fn target_extraction() {
107        assert_eq!(
108            oci_whiteout_target(OsStr::new(".wh.foo")),
109            Some(OsStr::new("foo"))
110        );
111        assert_eq!(oci_whiteout_target(OsStr::new("foo")), None);
112        assert_eq!(oci_whiteout_target(OsStr::new(OCI_OPAQUE_MARKER)), None);
113    }
114
115    #[test]
116    fn name_construction_roundtrips() {
117        let made = oci_whiteout_name(OsStr::new("payload"));
118        assert_eq!(made, OsString::from(".wh.payload"));
119        assert_eq!(oci_whiteout_target(&made), Some(OsStr::new("payload")));
120    }
121
122    #[test]
123    fn user_creation_rejected_in_oci() {
124        assert!(!is_user_creatable_name(
125            WhiteoutFormat::OciWhiteout,
126            OsStr::new(".wh.evil"),
127        ));
128        assert!(is_user_creatable_name(
129            WhiteoutFormat::OciWhiteout,
130            OsStr::new("normal"),
131        ));
132        // CharDev format places no name restriction.
133        assert!(is_user_creatable_name(
134            WhiteoutFormat::CharDev,
135            OsStr::new(".wh.allowed"),
136        ));
137    }
138
139    #[test]
140    fn default_per_platform() {
141        let want = if cfg!(target_os = "macos") {
142            WhiteoutFormat::OciWhiteout
143        } else {
144            WhiteoutFormat::CharDev
145        };
146        assert_eq!(WhiteoutFormat::default(), want);
147    }
148}