Skip to main content

projectatlas/
project_state_linux.rs

1//! Handle-bound Linux access policy and unnamed replacement preparation.
2
3use super::{
4    ProjectStateGuard, containing_directory, refusal, require_regular,
5    resolve_existing_parent_prefix,
6};
7use rustix::fs::{
8    AtFlags, CWD, Mode, OFlags, StatxAttributes, StatxFlags, fstatfs, ioctl_getflags, linkat,
9    openat, statx,
10};
11use std::fs::{File, Metadata, Permissions};
12use std::io::{self, Write};
13use std::os::fd::AsRawFd;
14use std::os::unix::fs::MetadataExt;
15use std::path::Path;
16use xattr::FileExt;
17
18/// Kernel-owned access attributes, excluding content-bound signatures and socket/dir labels.
19const ACCESS_ATTRIBUTES: [&str; 6] = [
20    "system.posix_acl_access",
21    "system.nfs4_acl",
22    "security.selinux",
23    "security.SMACK64",
24    "security.SMACK64EXEC",
25    "security.SMACK64MMAP",
26];
27
28/// Linux UAPI bits not named by the locked rustix `IFlags` (which retains them).
29const CASEFOLD_FLAG: u32 = 0x4000_0000;
30/// A sealed inode cannot be replaced with an unsealed generated file.
31const VERITY_FLAG: u32 = 0x0010_0000;
32
33/// Project fixed ASCII names under Linux's frozen Unicode 12.1 NFDICF model.
34///
35/// Derived from official UCD 12.1, independently checked against every assigned
36/// scalar: 19 assigned default-ignorable ranges and 13 ASCII-only expansions.
37/// Canonical reordering cannot make a remaining non-ASCII output equal ASCII.
38/// This is deliberately not a general Unicode normalizer or APFS comparison.
39pub(super) fn fixed_name_matches(name: &str, expected: &str) -> bool {
40    if !expected.is_ascii() {
41        return false;
42    }
43    let mut remaining = expected.as_bytes();
44    for character in name.chars() {
45        let ascii = [character.to_ascii_lowercase() as u8];
46        let chunk: &[u8] = match character {
47            '\u{00ad}'
48            | '\u{034f}'
49            | '\u{061c}'
50            | '\u{115f}'..='\u{1160}'
51            | '\u{17b4}'..='\u{17b5}'
52            | '\u{180b}'..='\u{180e}'
53            | '\u{200b}'..='\u{200f}'
54            | '\u{202a}'..='\u{202e}'
55            | '\u{2060}'..='\u{2064}'
56            | '\u{2066}'..='\u{206f}'
57            | '\u{3164}'
58            | '\u{fe00}'..='\u{fe0f}'
59            | '\u{feff}'
60            | '\u{ffa0}'
61            | '\u{1bca0}'..='\u{1bca3}'
62            | '\u{1d173}'..='\u{1d17a}'
63            | '\u{e0001}'
64            | '\u{e0020}'..='\u{e007f}'
65            | '\u{e0100}'..='\u{e01ef}' => b"",
66            '\u{00df}' | '\u{1e9e}' => b"ss",
67            '\u{017f}' => b"s",
68            '\u{037e}' => b";",
69            '\u{1fef}' => b"`",
70            '\u{212a}' => b"k",
71            '\u{fb00}' => b"ff",
72            '\u{fb01}' => b"fi",
73            '\u{fb02}' => b"fl",
74            '\u{fb03}' => b"ffi",
75            '\u{fb04}' => b"ffl",
76            '\u{fb05}' | '\u{fb06}' => b"st",
77            character if character.is_ascii() => &ascii,
78            _ => return false,
79        };
80        for byte in chunk {
81            match remaining.split_first() {
82                Some((candidate, suffix)) if candidate.to_ascii_lowercase() == *byte => {
83                    remaining = suffix;
84                }
85                _ => return false,
86            }
87        }
88    }
89    remaining.is_empty()
90}
91
92/// Compare only an already established potential managed namespace component.
93pub(super) fn named(path: &Path, expected: &str) -> io::Result<bool> {
94    let name = path
95        .file_name()
96        .and_then(std::ffi::OsStr::to_str)
97        .ok_or_else(|| refusal(path, "managed filename is not UTF-8"))?;
98    let parent = resolve_existing_parent_prefix(containing_directory(path))?;
99    let mut prefix = parent.as_path();
100    let directory = loop {
101        match openat(
102            CWD,
103            prefix,
104            OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
105            Mode::empty(),
106        ) {
107            Ok(directory) => break directory,
108            Err(rustix::io::Errno::NOENT) => {
109                prefix = prefix
110                    .parent()
111                    .ok_or_else(|| refusal(path, "managed containing directory is unavailable"))?;
112            }
113            Err(source) => return Err(source.into()),
114        }
115    };
116    // Linux superblock magic numbers are the low 32 bits, including on signed
117    // native fsword_t targets. Keep driver policy here, after namespace proof.
118    let filesystem = fstatfs(&directory)?.f_type as u32;
119    let flags = match ioctl_getflags(&directory) {
120        Ok(flags) => flags.bits(),
121        Err(rustix::io::Errno::NOTTY | rustix::io::Errno::OPNOTSUPP)
122            if matches!(filesystem, 0x0102_1994 | 0x9123_683e) =>
123        {
124            0
125        }
126        Err(source) => return Err(source.into()),
127    };
128    let folded = flags & CASEFOLD_FLAG != 0;
129    match filesystem {
130        // Mounted ext4/f2fs encoding maps select UTF-8 12.1, not a userspace
131        // locale or current Unicode library version.
132        0xef53 | 0xf2f5_2010 => Ok(folded && fixed_name_matches(name, expected)),
133        // XFS's legacy native insensitive model is ASCII-only.
134        0x5846_5342 => Ok(folded && name.eq_ignore_ascii_case(expected)),
135        // These native sensitive models require a flag for any casefold mode.
136        0x0102_1994 | 0x9123_683e if !folded => Ok(false),
137        _ => Err(refusal(
138            path,
139            "managed case-alias filesystem comparison model is unknown",
140        )),
141    }
142}
143
144/// Query a retained source's sealing state without opening a mutable pathname.
145fn require_unsealed(file: &File, path: &Path) -> io::Result<()> {
146    let sealed = match statx(file, "", AtFlags::EMPTY_PATH, StatxFlags::empty()) {
147        Ok(state) if state.stx_attributes_mask.contains(StatxAttributes::VERITY) => {
148            Some(state.stx_attributes.contains(StatxAttributes::VERITY))
149        }
150        Ok(_)
151        | Err(rustix::io::Errno::NOSYS | rustix::io::Errno::OPNOTSUPP | rustix::io::Errno::NOTTY) => {
152            None
153        }
154        Err(source) => return Err(source.into()),
155    };
156    let sealed = match sealed {
157        Some(sealed) => sealed,
158        None => match ioctl_getflags(file) {
159            Ok(flags) => flags.bits() & VERITY_FLAG != 0,
160            // A conforming fs-verity inode is observable through GETFLAGS on
161            // Linux 5.4, or statx since 5.5. Both unsupported means the feature
162            // is unavailable, not a permission or I/O failure to inspect it.
163            Err(rustix::io::Errno::NOTTY | rustix::io::Errno::OPNOTSUPP) => false,
164            Err(source) => return Err(source.into()),
165        },
166    };
167    if sealed {
168        return Err(refusal(
169            path,
170            "fs-verity-protected output cannot be replaced",
171        ));
172    }
173    Ok(())
174}
175
176/// A retained regular destination and its opaque native access policy.
177pub(super) struct ReplacementPolicy {
178    /// Keep the snapshotted inode alive through publication.
179    source: File,
180    /// Ownership, mode and filesystem identity associated with that inode.
181    metadata: Metadata,
182    /// Supported native attributes, including verified absence.
183    attributes: Vec<(&'static str, Option<Vec<u8>>)>,
184}
185
186impl ReplacementPolicy {
187    /// Snapshot through a no-follow descriptor, without requiring data-read access on write-only files.
188    pub(super) fn capture(path: &Path, expected: &Metadata) -> io::Result<Self> {
189        let flags = OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK;
190        let source = match openat(CWD, path, flags | OFlags::RDONLY, Mode::empty()) {
191            Ok(file) => file,
192            Err(rustix::io::Errno::ACCESS) => {
193                openat(CWD, path, flags | OFlags::WRONLY, Mode::empty())?
194            }
195            Err(source) => return Err(source.into()),
196        };
197        let source = File::from(source);
198        let metadata = source.metadata()?;
199        require_regular(path, &metadata)?;
200        if metadata.dev() != expected.dev() || metadata.ino() != expected.ino() {
201            return Err(refusal(
202                path,
203                "destination object changed before access capture",
204            ));
205        }
206        require_unsealed(&source, path)?;
207        let mut attributes = Vec::with_capacity(ACCESS_ATTRIBUTES.len());
208        for name in ACCESS_ATTRIBUTES {
209            match source.get_xattr(name) {
210                Ok(value) => attributes.push((name, value)),
211                Err(source) if source.kind() == io::ErrorKind::Unsupported => {}
212                Err(source) => return Err(source),
213            }
214        }
215        Ok(Self {
216            source,
217            metadata,
218            attributes,
219        })
220    }
221
222    /// Create a probe with the destination's ordinary mode, filtered by current umask.
223    pub(super) fn permissions(&self) -> Permissions {
224        self.metadata.permissions()
225    }
226
227    /// Prove the complete observable policy already matches, without any setters.
228    pub(super) fn matches(&self, file: &File) -> io::Result<bool> {
229        let current = file.metadata()?;
230        if current.dev() != self.metadata.dev()
231            || current.uid() != self.metadata.uid()
232            || current.gid() != self.metadata.gid()
233            || current.mode() != self.metadata.mode()
234        {
235            return Ok(false);
236        }
237        for (name, value) in &self.attributes {
238            if file.get_xattr(name)? != *value {
239                return Ok(false);
240            }
241        }
242        Ok(true)
243    }
244
245    /// Refuse replacement after the admitted destination or its captured policy changes.
246    pub(super) fn verify_destination(&self, path: &Path) -> io::Result<()> {
247        require_unsealed(&self.source, path)?;
248        let current = std::fs::symlink_metadata(path)?;
249        require_regular(path, &current)?;
250        if current.dev() != self.metadata.dev()
251            || current.ino() != self.metadata.ino()
252            || !self.matches(&self.source)?
253        {
254            return Err(refusal(path, "destination object or access policy changed"));
255        }
256        Ok(())
257    }
258
259    /// Apply only changed attributes, then prove the complete policy before naming.
260    pub(super) fn apply(&self, file: &File) -> io::Result<()> {
261        let current = file.metadata()?;
262        if current.uid() != self.metadata.uid() || current.gid() != self.metadata.gid() {
263            std::os::unix::fs::fchown(file, Some(self.metadata.uid()), Some(self.metadata.gid()))?;
264        }
265        for (name, value) in &self.attributes {
266            if file.get_xattr(name)? == *value {
267                continue;
268            }
269            if let Some(value) = value {
270                file.set_xattr(name, value)?;
271            } else {
272                file.remove_xattr(name)?;
273            }
274        }
275        if file.metadata()?.mode() != self.metadata.mode() {
276            file.set_permissions(self.metadata.permissions())?;
277        }
278        if !self.matches(file)? {
279            return Err(io::Error::new(
280                io::ErrorKind::InvalidInput,
281                "anonymous stage access policy differs from destination",
282            ));
283        }
284        Ok(())
285    }
286
287    /// Prepare the complete replacement while unnamed; only then expose a staging name.
288    pub(super) fn anonymous_stage(
289        &self,
290        builder: &tempfile::Builder<'_, '_>,
291        guard: &ProjectStateGuard,
292        bytes: &[u8],
293    ) -> io::Result<tempfile::NamedTempFile> {
294        builder.make_in(&guard.directory, |path| {
295            guard.verify_directories()?;
296            let mut file = File::from(openat(
297                CWD,
298                &guard.directory,
299                OFlags::TMPFILE | OFlags::RDWR | OFlags::CLOEXEC,
300                Mode::RUSR | Mode::WUSR,
301            )?);
302            // Complete data I/O under the creator's policy while the file has no
303            // name. A changed SELinux SID may revoke writes on this very handle.
304            file.write_all(bytes)?;
305            file.sync_all()?;
306            self.apply(&file)?;
307            file.sync_all()?;
308            if !self.matches(&file)? {
309                return Err(refusal(
310                    path,
311                    "anonymous stage access policy changed while writing",
312                ));
313            }
314            guard.verify_directories()?;
315            // AT_EMPTY_PATH requires CAP_DAC_READ_SEARCH. The proc-fd route is
316            // the documented unprivileged link alternative for O_TMPFILE.
317            match linkat(&file, "", CWD, path, AtFlags::EMPTY_PATH) {
318                Ok(()) => {}
319                Err(rustix::io::Errno::PERM | rustix::io::Errno::NOENT) => {
320                    guard.verify_directories()?;
321                    let descriptor = format!("/proc/self/fd/{}", file.as_raw_fd());
322                    linkat(CWD, descriptor.as_str(), CWD, path, AtFlags::SYMLINK_FOLLOW)?;
323                }
324                Err(source) => return Err(source.into()),
325            }
326            Ok(file)
327        })
328    }
329}